fix(pda): align return workflows with current test backend

This commit is contained in:
2026-09-29 23:25:00 +08:00
parent fddaaf71fb
commit 33c395937a
8 changed files with 31 additions and 333 deletions
-34
View File
@@ -55,37 +55,3 @@ export async function mergeReturnCartons(request) {
export async function getReturnCartonLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
}
function normalizePlanBox(box = {}) {
return {
boxNo: Number(box.boxNo || 0),
items: Array.isArray(box.items) ? box.items.map((item) => ({ skuCode: String(item.skuCode || ''), quantity: count(item.quantity) })) : [],
length: count(box.length), width: count(box.width), height: count(box.height), weight: count(box.weight),
}
}
export function normalizeReturnPackingPlan(value = {}) {
const skuLines = Array.isArray(value.skuLines) ? value.skuLines.map((item) => ({ skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), quantity: count(item.quantity) })) : []
const boxes = Array.isArray(value.boxes) && value.boxes.length ? value.boxes.map(normalizePlanBox) : skuLines.map((item, index) => ({ boxNo: index + 1, items: [{ skuCode: item.skuCode, quantity: item.quantity }], length: 0, width: 0, height: 0, weight: 0 }))
return {
planNo: String(value.planNo || ''), status: String(value.status || 'READY'), mode: String(value.mode || 'WAREHOUSE_RECOMMENDATION'),
customerName: String(value.customerName || ''), customerReferenceNo: String(value.customerReferenceNo || ''),
returnNos: Array.isArray(value.returnNos) ? value.returnNos.map((item) => String(item || '')).filter(Boolean) : [],
skuLines, boxes, remark: String(value.remark || ''),
}
}
export async function listReturnPackingPlans() {
const value = await overseasRequest('GET', `${BASE}/packing/plans`)
const records = Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
return records.map(normalizeReturnPackingPlan)
}
export async function getReturnPackingPlan(planNo) {
return normalizeReturnPackingPlan(await overseasRequest('GET', `${BASE}/packing/plans/${encoded(planNo)}`))
}
export async function mergeReturnPackingPlan(planNo, request) {
const value = await overseasRequest('POST', `${BASE}/packing/plans/${encoded(planNo)}/complete`, request)
return { ...value, labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [normalizeReturnLabel(value?.label || value)], label: normalizeReturnLabel(value?.label || value) }
}
+2 -17
View File
@@ -47,7 +47,7 @@ export function normalizeReturnSkuCandidates(value = {}) {
}
export function normalizeReturnReceiptResult(value = {}) {
const result = {
return {
returnNo: String(value.returnNo || ''),
receiptNo: String(value.receiptNo || ''),
receiptBatchNo: String(value.receiptBatchNo || ''),
@@ -58,17 +58,6 @@ export function normalizeReturnReceiptResult(value = {}) {
receivedQuantity: count(value.receivedQuantity),
signedAt: value.signedAt || null
}
if (value.cartonMark != null || value.printableHtml != null || value.totalBoxCount != null) {
result.cartonMark = String(value.cartonMark || '')
result.printableHtml = String(value.printableHtml || '')
result.totalBoxCount = count(value.totalBoxCount)
}
if (value.unitType != null || value.unitCode != null) {
result.unitType = String(value.unitType || (value.palletCode ? 'PALLET' : ''))
result.unitCode = String(value.unitCode || value.palletCode || '')
}
if (Array.isArray(value.labels)) result.labels = value.labels.map((item) => ({ cartonMark: String(item.cartonMark || ''), printableHtml: String(item.printableHtml || '') })).filter((item) => item.cartonMark && item.printableHtml)
return result
}
export async function startReturnReceipt(returnNo) {
@@ -91,11 +80,7 @@ export async function findReturnSkuCandidates(skuCode) {
}
export async function confirmReturnReceipt(request) {
const value = String(request?.palletCode || '')
const encoded = value.startsWith('BOX:') || value.startsWith('PARCEL:')
? { ...request, unitType: value.slice(0, value.indexOf(':')), unitCode: value.slice(value.indexOf(':') + 1), palletCode: undefined }
: request
return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, encoded))
return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request))
}
export async function createReturnPallet(returnNo, skuCode) {
+6 -37
View File
@@ -2,12 +2,7 @@
<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">
<template v-if="!result && !activePlan">
<view v-if="plans.length" class="work-card"><view class="row-head"><text class="card-title">待执行合箱计划</text><button class="link-button" :disabled="busy" @tap="loadPlans">刷新</button></view><button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="openPlan(plan)"><view class="row-head"><view><text class="business-code">{{ plan.planNo }}</text><text class="meta-line">{{ plan.mode === 'CUSTOMER_INSTRUCTION' ? '客户合箱指令' : '仓库自主合箱' }} · {{ plan.returnNos.length }} 个订单</text></view><text class="select-link">开始 ›</text></view><text class="meta-line">SKU {{ plan.skuLines.length }} 种 · 计划 {{ plan.boxes.length }} 箱</text></button></view>
<view 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>
</template>
<template v-if="activePlan && !result"><view class="work-card"><view class="row-head"><view><text class="card-title">{{ activePlan.planNo }}</text><text class="meta-line">{{ activePlan.mode === 'CUSTOMER_INSTRUCTION' ? '客户合箱指令' : '仓库自主合箱' }} · 来源订单 {{ activePlan.returnNos.length }} 个</text></view><button class="link-button" @tap="closePlan">返回列表</button></view><text class="hint">计划未配置箱明细时,系统已按单 SKU 一箱生成默认方案。可以合并为混箱,也可以拆成多个单箱。</text></view><view v-for="(box, index) in planBoxes" :key="index" class="work-card"><view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button class="link-button" :disabled="planBoxes.length <= 1" @tap="removePlanBox(index)">删除箱</button></view><view v-for="(item, itemIndex) in box.items" :key="itemIndex" class="quantity-row"><input class="number-input plan-sku-input" v-model="item.skuCode" placeholder="SKU" /><input class="number-input" type="number" v-model="item.quantity" placeholder="数量" /><button class="link-button" @tap="removePlanItem(box, itemIndex)">移除</button></view><button class="secondary" @tap="addPlanItem(box)">添加 SKU 到本箱</button><view class="dimension-grid"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" v-model="box[field.key]" :placeholder="field.placeholder" /></label></view></view><button class="secondary" @tap="addPlanBox">添加箱</button><button class="primary" :disabled="!canCompletePlan || busy" @tap="completePlan">{{ busy ? '生成中…' : '确认合箱并生成箱唛' }}</button></template>
<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>
<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>
@@ -18,7 +13,7 @@
<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">退货合箱成功,共 {{ (result.labels || [result.label]).length }} 箱</text><view v-for="label in (result.labels || [result.label])" :key="label.cartonMark" class="item-card"><text class="result-carton">{{ label.cartonMark }}</text><text class="meta-line">来源退货单:{{ label.sourceReturnNo || '—' }}</text><text class="meta-line">箱规:{{ sizeText(label) }}</text><view class="item-list section-gap"><view v-for="item in label.items" :key="item.skuCode" class="row-head"><text class="business-code">{{ item.skuCode }}</text><text class="remaining-copy">{{ item.quantity }} 件</text></view></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">退货合箱成功</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="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>
@@ -27,7 +22,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { findPackingOrders, listReturnPackingPlans, mergeReturnCartons, mergeReturnPackingPlan } from '@/api/return-packing.js'
import { findPackingOrders, mergeReturnCartons } from '@/api/return-packing.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'
@@ -35,7 +30,7 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, candidates: null, previews: {}, plans: [], activePlan: null, planBoxes: [], 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: '重量' }] } },
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: '重量' }] } },
computed: {
packing() { return warehouseWork.returnPacking },
searchCode: { get() { return this.packing.searchCode }, set(value) { updateWarehouseWork('returnPacking', { searchCode: value }) } },
@@ -43,37 +38,11 @@
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()] },
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) },
canCompletePlan() { return this.planBoxes.length > 0 && this.planBoxes.every((box) => box.items.some((item) => String(item.skuCode || '').trim() && Number(item.quantity) > 0) && this.dimensionFields.every((field) => Number(box[field.key]) > 0)) }
canMerge() { return this.selectedOrders.length > 0 && this.mergeQuantity > 0 && this.validDimensions && 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(); this.loadPlans(); 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.startScanner(); if (this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
async loadPlans() { try { this.plans = await listReturnPackingPlans() } catch (error) { this.plans = []; this.setError(error, '合箱计划加载失败') } },
openPlan(plan) { this.activePlan = plan; this.planBoxes = (plan.boxes || []).map((box) => ({ ...box, items: (box.items || []).map((item) => ({ ...item })) })) },
closePlan() { this.activePlan = null; this.planBoxes = [] },
addPlanBox() { this.planBoxes.push({ boxNo: this.planBoxes.length + 1, items: [{ skuCode: '', quantity: 0 }], length: 0, width: 0, height: 0, weight: 0 }) },
removePlanBox(index) { if (this.planBoxes.length > 1) this.planBoxes.splice(index, 1) },
addPlanItem(box) { box.items.push({ skuCode: '', quantity: 0 }) },
removePlanItem(box, index) { if (box.items.length > 1) box.items.splice(index, 1) },
async completePlan() {
if (!this.canCompletePlan || this.busy || !this.activePlan) return
await this.run(async () => {
const planNo = this.activePlan.planNo
const boxes = this.planBoxes.map((box, index) => ({
boxNo: index + 1,
items: box.items.filter((item) => String(item.skuCode || '').trim() && Number(item.quantity) > 0).map((item) => ({ skuCode: String(item.skuCode).trim(), quantity: Number(item.quantity) })),
length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight)
}))
const key = warehouseIdempotencyKey('returnPackingPlan', 'complete', { planNo, boxes })
this.result = await mergeReturnPackingPlan(planNo, { boxes, idempotencyKey: key.value })
releaseWarehouseKey('returnPackingPlan', key.signature)
this.activePlan = null
this.planBoxes = []
await this.loadPlans()
playSuccessSound()
}, '合箱计划执行失败')
},
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (!this.busy) { 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() }, '查询可合箱退货单失败') },
+19 -82
View File
@@ -2,45 +2,38 @@
<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="!current && !result && !receiptBatch" class="work-card">
<view v-if="!current && !result" class="work-card">
<text class="card-title">扫描 SKU 69 码</text>
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<text class="scan-hint">扫描完成后自动匹配对应退货单</text>
</view>
<view v-if="!current && candidates !== null && !result && !receiptBatch" class="section-gap">
<view v-if="!current && candidates !== null && !result" class="section-gap">
<view class="row-head candidate-heading"><text class="card-title">匹配退货单</text><text class="meta-line">{{ candidates.length }} 个候选</text></view>
<view v-if="!candidates.length" class="empty-box"><text class="empty-copy">{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}</text></view>
<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>
<template v-if="current && !result && !receiptBatch">
<template v-if="current && !result">
<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">
<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>
<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">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
</template>
<text class="card-title">本托信息</text>
<text class="hint">本次签收按一个 SKU 打一个托盘,托盘码必填。</text>
<text class="field-label">托盘码</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><button v-if="newPalletHtml" class="secondary" @tap="printPallet">打印托盘标签</button>
<text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" />
</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="work-card"><view class="row-head"><text class="card-title">本托实收数量</text><button class="link-button" @tap="fillRemaining">全部填入</button></view><view 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></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>
<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><text 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="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 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" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
</view></scroll-view>
</view>
</template>
@@ -50,104 +43,51 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { checkReturnPallet, confirmReturnReceipt, createReturnPallet, downloadReturnPalletPhoto, findReturnSkuCandidates, manuallyCompleteReturnReceipt, startReturnReceipt, uploadReturnPalletPhoto } from '@/api/return-receipt.js'
import { session, requireSession } from '@/stores/session.js'
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.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, 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: '', 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 }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
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) || [] },
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)); },
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) : '—' }
receiptQuantity() { return Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { return this.current && this.palletCode.trim() && this.palletCheck && this.selectedItems.length === 1 && this.receiptQuantity > 0 && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item)) }
},
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) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (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() },
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.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'pallet') this.acceptPallet(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') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value) }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
validQuantities(order) { const source = warehouseWork.returnReceipt.quantities || {}; return Object.fromEntries(order.expectedItems.map((item) => [item.skuCode, Math.min(Math.max(0, Number(source[item.skuCode]) || 0), this.remaining(item))])) },
quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[skuCode]) || 0) },
remaining(item) { return Math.max(0, Number(item.expectedQuantity || 0) - Number(item.receivedQuantity || 0)) },
setQuantity(item, raw) { const maximum = this.remaining(item); const value = Math.min(maximum, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnReceipt', { quantities: { ...(warehouseWork.returnReceipt.quantities || {}), [item.skuCode]: value } }) },
setBoxCount(item, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); updateWarehouseWork('returnReceipt', { boxCounts: { ...(warehouseWork.returnReceipt.boxCounts || {}), [item.skuCode]: value } }) },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.selectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
async acceptPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code || this.busy) return; if (!this.activeSkuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '托盘校验失败'); return } await this.run(async () => { const value = await checkReturnPallet(this.current.returnNo, code); this.palletCode = value.palletCode; this.newPalletHtml = ''; this.palletCheck = value; this.activeTarget = 'tracking'; playSuccessSound() }, '托盘校验失败') },
async generatePallet() { if (!this.current || this.busy) return; const skuCode = this.activeSkuCode; if (!skuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '无法生成退货托盘'); return } await this.run(async () => { const value = await createReturnPallet(this.current.returnNo, skuCode); if (!value.palletCode) throw new Error('服务端未返回新托盘码'); this.palletCode = value.palletCode; this.newPalletHtml = value.printableHtml; this.palletCheck = { palletCode: value.palletCode, empty: true, skuCodes: [], quantity: 0 }; this.activeTarget = 'tracking'; playSuccessSound() }, '生成退货托盘失败') },
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 }
await this.run(async () => {
const rows = this.unitType === 'BOX' ? 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 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 })) })
await this.resumeReceiptBatch()
} else {
this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
}
}, '退货入库失败')
},
async resumeReceiptBatch() {
const batch = this.receiptBatch
const scopeKey = warehouseWork.scopeKey
if (!batch) return
for (let index = 0; index < batch.entries.length; index++) {
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
if (batch.entries[index].result) continue
// Persist the immutable request before every attempt; storage failures must stop submission.
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
const result = await confirmReturnReceipt(batch.entries[index].request)
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
if (!result) throw new Error('服务端未返回签收结果,请重试本批签收')
batch.entries[index].result = result
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
}
this.finishReceipt(batch.entries.map(entry => entry.result))
},
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 })) }
resetWarehouseDomain('returnReceipt')
this.current = null
this.candidates = null
this.activeTarget = 'lookup'
playSuccessSound()
},
async confirmReceipt() { if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => 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 } const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, palletCode: this.palletCode.trim(), items: this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode) })).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', 'confirm', payload); await this.run(async () => { this.result = await confirmReturnReceipt({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnReceipt', key.signature); resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; 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() }, '退货打托照片上传失败') } }) },
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' },
clearCurrent() { resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
nextReceipt() { this.result = null; this.clearCurrent() },
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() },
@@ -160,7 +100,4 @@
@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}
.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}
</style>
+1 -3
View File
@@ -6,9 +6,8 @@ const blankState = () => ({
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
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: {} },
returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
returnPackingPlan: { pendingKeys: {} },
warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
})
@@ -41,7 +40,6 @@ export function restoreWarehouseWork(scopeKey) {
stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move),
relabel: domain(saved.relabel, fresh.relabel), returnReceipt: domain(saved.returnReceipt, fresh.returnReceipt),
returnPacking: domain(saved.returnPacking, fresh.returnPacking),
returnPackingPlan: domain(saved.returnPackingPlan, fresh.returnPackingPlan),
warehouseTransfer: domain(saved.warehouseTransfer, fresh.warehouseTransfer),
query: { ...fresh.query, ...(saved.query || {}) }
})
+1 -9
View File
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { findPackingOrders, getReturnCartonLabel, listReturnPackingPlans, mergeReturnCartons, normalizePackingPreview } from '../src/api/return-packing.js'
import { findPackingOrders, getReturnCartonLabel, mergeReturnCartons, normalizePackingPreview } from '../src/api/return-packing.js'
import { session } from '../src/stores/session.js'
test('packing preview keeps customer scope internal and exposes stable order references', () => {
@@ -40,11 +40,3 @@ test('return packing lookup still rejects real service errors', async () => {
globalThis.uni = { request(options) { options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} }) } }
await assert.rejects(() => findPackingOrders('RT-1'), (error) => error.code === 'B0500')
})
test('plan list distinguishes an empty success from a missing API and preserves diagnostics', async () => {
session.apiBase = '/api'; session.token = 'packing-token'
globalThis.uni = { request(options) { options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } }
assert.deepEqual(await listReturnPackingPlans(), [])
globalThis.uni = { request(options) { options.success({ statusCode: 404, data: { code: 'A0404', message: '计划接口不存在', requestId: 'missing-plan-api' }, header: {} }) } }
await assert.rejects(() => listReturnPackingPlans(), error => error.code === 'A0404' && error.requestId === 'missing-plan-api')
})
-49
View File
@@ -1,49 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
function setup(merge) {
const saved = new Map()
const context = vm.createContext({ reactive: value => value, PortalHeader: {}, ScanTargetInput: {},
playSuccessSound() {}, playErrorSound() {}, mergeReturnPackingPlan: merge,
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key) } })
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 source = readFileSync(new URL('../src/pages/return-packing/return-packing.vue', import.meta.url), 'utf8')
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context)
vm.runInContext("restoreWarehouseWork('operator:warehouse')", context)
const page = Object.assign({}, component.methods, { canCompletePlan: true, busy: false,
activePlan: { planNo: 'PLAN-1' }, planBoxes: [{ items: [{ skuCode: ' SKU-1 ', quantity: '2' }], length: 10, width: 20, height: 30, weight: 1 }],
async loadPlans() {} })
return { page, restore: scope => vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context),
keys: () => vm.runInContext('warehouseWork.returnPackingPlan.pendingKeys', context) }
}
test('plan completion reaches API and preserves the key across lost responses and draft restore', async () => {
const calls = []; let fail = true
const fixture = setup(async (plan, payload) => { calls.push({ plan, payload }); if (fail) throw new Error('response lost'); return { labels: [] } })
await fixture.page.completePlan()
assert.equal(calls.length, 1)
assert.equal(fixture.page.error.message, 'response lost')
assert.equal(fixture.page.busy, false)
fixture.restore('operator:warehouse')
fail = false
await fixture.page.completePlan()
assert.equal(calls.length, 2)
assert.equal(calls[0].payload.idempotencyKey, calls[1].payload.idempotencyKey)
assert.equal(calls[1].payload.boxes[0].items[0].skuCode, 'SKU-1')
assert.equal(fixture.page.activePlan, null)
assert.equal(Object.keys(fixture.keys()).length, 0)
})
test('plan completion keys are not reused by another operator or warehouse', async () => {
const keys = []
const fixture = setup(async (_plan, payload) => { keys.push(payload.idempotencyKey); throw new Error('response lost') })
await fixture.page.completePlan()
fixture.restore('other-operator:warehouse')
await fixture.page.completePlan()
assert.equal(keys.length, 2)
assert.notEqual(keys[0], keys[1])
})
+2 -102
View File
@@ -20,8 +20,6 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
quantity: () => 2, boxCount: () => 1
})
instance.changeScope = scope => vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context)
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
return instance
}
@@ -32,6 +30,8 @@ test('PALLET page submits the required palletCode through the actual confirmatio
assert.equal(calls.length, 1)
assert.equal(calls[0].palletCode, 'PLT-TEST')
assert.equal(calls[0].unitCode, undefined)
assert.deepEqual(Object.keys(calls[0]).sort(), ['idempotencyKey', 'items', 'palletCode', 'returnNo', 'startedBy', 'trackingNo'].sort())
assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['receivedQuantity', 'skuCode'].sort())
assert.equal(calls[0].returnNo, 'RT-TEST')
assert.equal(calls[0].trackingNo, 'TRACK-TEST')
assert.equal(calls[0].items[0].receivedQuantity, 2)
@@ -50,103 +50,3 @@ 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)
})
function boxPage(confirm, saved, scope) {
return Object.assign(setup(confirm, saved, scope), { unitType: 'BOX', boxRows: [{ items: { 'SKU-TEST': 1 } }, { items: { 'SKU-TEST': 2 } }] })
}
test('BOX partial success resumes only unconfirmed boxes with the original payload and key', async () => {
const calls = []; let fail = true
const page = boxPage(async payload => {
calls.push(structuredClone(payload))
if (payload.items[0].receivedQuantity === 2 && fail) throw new Error('response lost')
return { unitType: 'BOX', cartonMark: `BOX-${payload.items[0].receivedQuantity}`, printableHtml: '<p>label</p>' }
})
await page.confirmReceipt()
assert.equal(calls.length, 2)
page.boxRows[0].items['SKU-TEST'] = 3
page.boxRows[1].items['SKU-TEST'] = 4
page.trackingNo = 'changed'
fail = false
await page.confirmReceipt()
assert.equal(calls.length, 3)
assert.deepEqual(calls[2], calls[1])
assert.equal(page.result.labels.length, 2)
assert.equal(page.receiptBatch, null)
})
test('BOX progress survives a new page instance even when the order no longer allows a fresh receipt', async () => {
const saved = new Map(); const calls = []; let fail = true
const confirm = async payload => {
calls.push(structuredClone(payload))
if (payload.items[0].receivedQuantity === 2 && fail) throw new Error('response lost')
return { unitType: 'BOX', cartonMark: `BOX-${payload.items[0].receivedQuantity}`, printableHtml: '<p>label</p>' }
}
await boxPage(confirm, saved).confirmReceipt()
const restored = boxPage(confirm, saved)
restored.current = null; restored.canConfirm = false
fail = false
await restored.confirmReceipt()
assert.equal(calls.length, 3)
assert.deepEqual(calls[2], calls[1])
assert.equal(restored.result.labels.length, 2)
})
test('pending BOX receipt cannot be discarded by switching order and does not leak to another operator', async () => {
const saved = new Map()
const page = boxPage(async () => { throw new Error('response lost') }, saved)
await page.confirmReceipt()
page.clearCurrent()
assert.ok(page.receiptBatch)
assert.equal(page.current.returnNo, 'RT-TEST')
const other = boxPage(async () => { throw new Error('must not submit') }, saved, 'other:warehouse')
assert.equal(other.receiptBatch, null)
})
test('BOX request is not sent when the immutable batch cannot be persisted', async () => {
class FailingStorage extends Map {
set(key, value) { if (value.returnReceipt?.pendingBatch) throw new Error('storage unavailable'); return super.set(key, value) }
}
let calls = 0
const page = boxPage(async () => { calls++; return {} }, new FailingStorage())
await page.confirmReceipt()
assert.equal(calls, 0)
assert.equal(page.error.message, 'storage unavailable')
assert.equal(page.busy, false)
})
test('BOX lost progress persistence replays only with the same durable key after reload', async () => {
class OnceFailingStorage extends Map {
fail = true
set(key, value) {
if (this.fail && value.returnReceipt?.pendingBatch?.entries[0].result) { this.fail = false; throw new Error('storage unavailable') }
return super.set(key, value)
}
}
const saved = new OnceFailingStorage(); const calls = []
const confirm = async payload => { calls.push(structuredClone(payload)); return { unitType: 'BOX', cartonMark: 'BOX', printableHtml: '<p>label</p>' } }
const first = boxPage(confirm, saved)
await first.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(first.error.message, 'storage unavailable')
const restored = boxPage(confirm, saved)
await restored.confirmReceipt()
assert.equal(calls.length, 3)
assert.deepEqual(calls[0], calls[1])
assert.equal(restored.result.labels.length, 2)
})
test('BOX response arriving after account change cannot repopulate another account draft', async () => {
const saved = new Map(); let calls = 0
const page = boxPage(async () => {
calls++
// Simulate the real shared store changing identity while the request is in flight.
page.changeScope('other:warehouse')
return { unitType: 'BOX', cartonMark: 'BOX' }
}, saved)
await page.confirmReceipt()
assert.equal(calls, 1)
assert.equal(page.receiptBatch, null)
assert.match(page.error.message, /账号或仓库已改变/)
})