Merge remote-tracking branch 'origin/release/test' into release/test

This commit is contained in:
2026-09-29 20:58:10 +08:00
8 changed files with 144 additions and 36 deletions
+5 -2
View File
@@ -54,6 +54,7 @@ export function normalizeOutboundTask(value) {
...header,
taskNo: String(header?.taskNo || ''),
outboundNo: String(header?.outboundNo || ''),
outboundType: String(header?.outboundType || ''),
status: String(header?.status || ''),
items: asList(value?.items).map((item) => ({
...item,
@@ -117,12 +118,14 @@ export function pickOutboundPallet(taskNo, palletNo, request) {
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
}
export function outboundPickPayload(item, position, packages) {
export function outboundPickPayload(item, position, packages, scannedCode = '') {
const skuScan = item.skuCode && scannedCode === item.skuCode
return {
outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: position.inventoryPositionId,
packages: Number(packages),
cartonMarks: position.cartonMark ? [position.cartonMark] : []
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
...(skuScan ? { skuCode: item.skuCode } : {})
}
}
+37
View File
@@ -55,3 +55,40 @@ 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() {
try {
const value = await overseasRequest('GET', `${BASE}/packing/plans`)
const records = Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
return records.map(normalizeReturnPackingPlan)
}
catch (error) { if (isNotFound(error)) return []; throw error }
}
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) }
}
+17 -2
View File
@@ -47,7 +47,7 @@ export function normalizeReturnSkuCandidates(value = {}) {
}
export function normalizeReturnReceiptResult(value = {}) {
return {
const result = {
returnNo: String(value.returnNo || ''),
receiptNo: String(value.receiptNo || ''),
receiptBatchNo: String(value.receiptBatchNo || ''),
@@ -58,6 +58,17 @@ 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) {
@@ -80,7 +91,11 @@ export async function findReturnSkuCandidates(skuCode) {
}
export async function confirmReturnReceipt(request) {
return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, 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))
}
export async function createReturnPallet(returnNo, skuCode) {
+11 -10
View File
@@ -3,12 +3,12 @@
<PortalHeader title="出库作业" :user="user" />
<scroll-view class="fulfillment-scroll" scroll-y>
<view class="portal-content fulfillment-content">
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">一件代发扫描托盘码可整托出库;扫描排车业务号会转入“排车拣货”</text></view>
<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" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
<text class="scan-help">任务号直接恢复;进入任务后扫描 DP 托盘码,将一次拣取该托全部未拣箱件。</text>
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
</view>
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
@@ -29,9 +29,10 @@
<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 class="task-head"><text class="business-code">{{ item.palletNo || '散件出库' }}</text><text>{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱</text></view>
<view v-for="position in item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
<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="!item.positions.length && item.pickedPackages < item.plannedPackages" class="missing-scan-contract">当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。</text>
</view>
</view>
@@ -65,7 +66,7 @@
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, 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: {}, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
computed: {
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages },
@@ -87,7 +88,7 @@
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
updateScanCode(value) { this.scanCode = value; updateFulfillmentDraft('outbound', { scanCode: value }) },
updatePackages(event) { const value = Math.trunc(Number(event.detail.value || 0)); updateFulfillmentDraft('outbound', { packages: Number.isFinite(value) ? Math.max(0, value) : 0 }) },
async loadTasks() { await this.run('加载出库任务', async () => { const [tasks, discardTasks] = await Promise.all([listOutboundTasks(), listCargoDiscardTasks()]); this.tasks = tasks; this.discardTasks = discardTasks; this.current = null; this.discardCurrent = null; this.discardTask = null }, true) },
async loadTasks() { await this.run('加载出库任务', async () => { const [tasks, discardTasks] = await Promise.all([listOutboundTasks('PENDING'), listCargoDiscardTasks()]); this.tasks = tasks; this.discardTasks = discardTasks; this.current = null; this.discardCurrent = null; this.discardTask = null }, true) },
async openTask(taskNo) { const code = String(taskNo || '').trim(); if (!code) return; await this.run('恢复出库任务', async () => { const pendingScan = fulfillment.outbound.currentTaskNo === code ? fulfillment.outbound.scanCode : ''; this.current = await getOutboundTask(code); this.selectedItem = null; this.selectedPosition = null; this.scanCode = pendingScan; updateFulfillmentDraft('outbound', { currentTaskNo: this.current.taskNo, scanCode: pendingScan, packages: pendingScan ? fulfillment.outbound.packages : 1 }); if (pendingScan) this.findAndSelectPosition(pendingScan, false) }, true) },
async openScannedTask(businessNo) {
await this.run('解析出库作业', async () => {
@@ -121,12 +122,12 @@
if (this.findAndSelectPosition(code)) return
this.setError(new Error('扫码未匹配当前出库任务的托盘码、箱码、SKU 或入仓码'), '扫码未匹配')
},
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { const position = item.positions.find((value) => [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position); if (feedback) playSuccessSound(); return true } } return false },
selectPosition(item, position) { if (!position || position.availablePackages <= 0) return; this.selectedItem = item; this.selectedPosition = position; updateFulfillmentDraft('outbound', { scanCode: this.lineBusinessCode(position), packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { if (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo) continue; const position = item.positions.find((value) => value.availablePackages > 0 && [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position, code); if (feedback) playSuccessSound(); return true } } return false },
selectPosition(item, position, scannedCode = '') { if (!position || position.availablePackages <= 0 || (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo)) return; this.selectedItem = item; this.selectedPosition = position; this.selectedScanCode = scannedCode || this.lineBusinessCode(position); updateFulfillmentDraft('outbound', { scanCode: this.selectedScanCode, packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
async submitPick() {
if (!this.canPick) return
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
},
async submitPalletPick(palletNo) {
const code = String(palletNo || '').trim(); if (!code || !this.current) return
@@ -144,7 +145,7 @@
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
businessLineKey(position) { return [position?.cartonMark, position?.palletNo, position?.skuCode, position?.inboundCode].filter(Boolean).join(':') || `line-${position?.inventoryPositionId || 'missing'}` },
statusLabel(status) { return ({ PENDING: '待拣货', IN_PROGRESS: '拣货中', COMPLETED: '已完成', CANCELLED: '已取消' })[status] || status || '状态未知' },
taskType() { return '普通/一件代发共用出库' }
taskType(task) { return ({ RETURN_OUTBOUND: '退货出库', RELABEL_OUTBOUND: '换标出库', ONE_CLICK_DROPSHIP: '一件代发出库', WAREHOUSE_TRANSFER_OUTBOUND: '仓储转运出库' })[task?.outboundType] || '普通出库' }
}
}
</script>
+20 -6
View File
@@ -2,7 +2,12 @@
<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>
<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="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>
@@ -13,7 +18,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">退货合箱成功</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 || [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="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,7 +27,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { findPackingOrders, mergeReturnCartons } from '@/api/return-packing.js'
import { findPackingOrders, listReturnPackingPlans, mergeReturnCartons, mergeReturnPackingPlan } 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'
@@ -30,7 +35,7 @@
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: '重量' }] } },
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: '重量' }] } },
computed: {
packing() { return warehouseWork.returnPacking },
searchCode: { get() { return this.packing.searchCode }, set(value) { updateWarehouseWork('returnPacking', { searchCode: value }) } },
@@ -38,11 +43,20 @@
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) }
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)) }
},
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.startScanner(); this.loadPlans(); 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; const payload = { 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) })), idempotencyKey: warehouseIdempotencyKey('returnPackingPlan', 'complete', { planNo: this.activePlan.planNo, boxes: this.planBoxes }).value }; await this.run(async () => { this.result = await mergeReturnPackingPlan(this.activePlan.planNo, payload); 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() }, '查询可合箱退货单失败') },
+33 -12
View File
@@ -18,20 +18,26 @@
<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">本次签收按一个 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" />
<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>
</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="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>
<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><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="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="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>
@@ -49,7 +55,7 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', 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: {} }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
computed: {
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
@@ -57,31 +63,43 @@
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 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)) }
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) : '—' }
},
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) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'pallet') this.acceptPallet(value); else this.acceptTracking(value) },
handleScan(value) { if (this.busy) 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') },
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: {} }); 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() },
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() },
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.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() }, '退货入库失败') },
async confirmReceipt() { 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 results = []; for (const [index, box] of rows.entries()) { const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PALLET' ? this.palletCode.trim() : this.unitType === 'PARCEL' ? this.unitCode.trim() : undefined, palletCode: 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); results.push(await confirmReturnReceipt({ ...payload, idempotencyKey: key.value })); releaseWarehouseKey('returnReceipt', key.signature) } 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() }, '退货入库失败') },
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 }) },
@@ -100,4 +118,7 @@
@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 -1
View File
@@ -6,7 +6,7 @@ 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: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, pendingKeys: {} },
returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
+20 -3
View File
@@ -19,13 +19,30 @@ test('outbound partial pick submits the selected carton and distinguishes shared
assert.notDeepEqual(first, outboundPickPayload({ outboundPalletItemId: '12' }, { inventoryPositionId: '22', cartonMark: 'BOX-B' }, 1))
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, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages\)/)
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 \}\)/)
assert.match(page, /item\.palletNo === code/)
})
test('return outbound preserves its task type and only permits pallet scans for pallet items', async () => {
const task = normalizeOutboundTask({ task: { taskNo: 'PDA-1', outboundType: 'RETURN_OUTBOUND' }, items: [] })
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, /整托出库请扫描托盘码/)
const item = { outboundPalletItemId: '12', skuCode: 'SKU-A' }
const position = { inventoryPositionId: '', cartonMark: 'RC-1' }
assert.deepEqual(outboundPickPayload(item, position, 1, 'SKU-A'), {
outboundPalletItemId: '12', inventoryPositionId: '', packages: 1, cartonMarks: [], skuCode: 'SKU-A'
})
assert.deepEqual(outboundPickPayload(item, position, 1, 'RC-1'), {
outboundPalletItemId: '12', inventoryPositionId: '', packages: 1, cartonMarks: ['RC-1']
})
})
function storageMock() {
const values = new Map()
globalThis.uni = {
@@ -159,8 +176,8 @@ test('pages expose loading empty error offline and permission diagnostics with g
assert.doesNotMatch(dispatch, /departedNo/)
assert.match(outbound, /completeOutboundTask/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
assert.match(outbound, /一件代发扫描托盘码可整托出库/)
assert.match(outbound, /扫描 DP 托盘码,将一次拣取该托全部未拣箱件/)
assert.match(outbound, /整托扫描托盘码,散箱扫描箱唛/)
assert.match(outbound, /退货整托出库扫托盘码,散箱出库扫箱唛/)
assert.match(outbound, /if \(!this\.current\) return this\.openScannedTask\(code\)/)
assert.doesNotMatch(outbound, /if \(!this\.current\)[^\n]*openTask\(code\)/)
assert.match(outbound, /this\.remainingPackages === 0/)