feat: add overseas return operations
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
- 工作台定义:`src/data/overseas-workbench.js`。
|
||||
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
|
||||
- 扫码设备层:`src/utils/scan-broadcast.js`;广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
|
||||
- 入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;库内盘点、移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。
|
||||
- 入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;退货入库通过退货单号或 SKU 候选进入并按本包实收生成退货箱与计费批次;库内盘点、移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。
|
||||
|
||||
## 页面与能力快速索引
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块与作业可用性入口;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
|
||||
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
|
||||
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
|
||||
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
|
||||
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
|
||||
| 库存盘点真实作业 | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 创建/恢复盘点任务,扫描库位和库存后计数、提交;严格使用服务端版本和幂等键 |
|
||||
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { overseasRequest } from './overseas.js'
|
||||
|
||||
const BASE = '/pda/v1/last-leg/return-inventory'
|
||||
const encoded = (value) => encodeURIComponent(String(value || '').trim())
|
||||
const count = (value) => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0
|
||||
|
||||
function normalizeItems(items) {
|
||||
return Array.isArray(items) ? items.map((item) => ({
|
||||
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''),
|
||||
quantity: count(item.quantity ?? item.availableQuantity)
|
||||
})) : []
|
||||
}
|
||||
|
||||
export function normalizePackingPreview(value = {}) {
|
||||
return {
|
||||
customerKey: value.customerId == null ? '' : String(value.customerId),
|
||||
orders: Array.isArray(value.orders) ? value.orders.map((item) => ({
|
||||
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
|
||||
})) : [],
|
||||
skus: Array.isArray(value.skus) ? value.skus.map((item) => ({
|
||||
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
|
||||
})) : []
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReturnLabel(value = {}) {
|
||||
return {
|
||||
cartonMark: String(value.cartonMark || ''), sourceBatchNo: String(value.sourceBatchNo || ''),
|
||||
sourceReturnNo: String(value.sourceReturnNo || ''), totalQuantity: count(value.totalQuantity),
|
||||
length: count(value.length), width: count(value.width), height: count(value.height), weight: count(value.weight),
|
||||
items: normalizeItems(value.items), generatedAt: value.generatedAt || null, printableHtml: String(value.printableHtml || '')
|
||||
}
|
||||
}
|
||||
|
||||
export async function findPackingOrders(returnNo = '') {
|
||||
const query = String(returnNo || '').trim()
|
||||
return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`))
|
||||
}
|
||||
|
||||
export async function mergeReturnCartons(request) {
|
||||
const value = await overseasRequest('POST', `${BASE}/cartons/merge`, request)
|
||||
return { cartonMark: String(value?.cartonMark || ''), skuCode: String(value?.skuCode || ''), totalQuantity: count(value?.totalQuantity), label: normalizeReturnLabel(value?.label) }
|
||||
}
|
||||
|
||||
export async function getReturnCartonLabel(cartonMark) {
|
||||
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { overseasRequest } from './overseas.js'
|
||||
|
||||
const BASE = '/pda/v1/last-leg/return-receipts'
|
||||
|
||||
function count(value) {
|
||||
const result = Number(value || 0)
|
||||
return Number.isFinite(result) ? Math.max(0, result) : 0
|
||||
}
|
||||
|
||||
function normalizeItem(item = {}) {
|
||||
return {
|
||||
skuCode: String(item.skuCode || ''),
|
||||
productNameZh: String(item.productNameZh || ''),
|
||||
expectedQuantity: count(item.expectedQuantity),
|
||||
receivedQuantity: count(item.receivedQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReturnReceiptStart(value = {}) {
|
||||
return {
|
||||
returnNo: String(value.returnNo || ''),
|
||||
status: String(value.status || ''),
|
||||
expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : []
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReturnSkuCandidates(value = {}) {
|
||||
return {
|
||||
skuCode: String(value.skuCode || ''),
|
||||
candidates: Array.isArray(value.candidates) ? value.candidates.map((item) => ({
|
||||
returnNo: String(item.returnNo || ''),
|
||||
customerReferenceNo: String(item.customerReferenceNo || ''),
|
||||
status: String(item.status || ''),
|
||||
skuExpectedQuantity: count(item.skuExpectedQuantity),
|
||||
skuReceivedQuantity: count(item.skuReceivedQuantity),
|
||||
orderExpectedQuantity: count(item.orderExpectedQuantity),
|
||||
orderReceivedQuantity: count(item.orderReceivedQuantity)
|
||||
})) : []
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReturnReceiptResult(value = {}) {
|
||||
return {
|
||||
returnNo: String(value.returnNo || ''),
|
||||
receiptNo: String(value.receiptNo || ''),
|
||||
receiptBatchNo: String(value.receiptBatchNo || ''),
|
||||
cartonMark: String(value.cartonMark || ''),
|
||||
orderStatus: String(value.orderStatus || ''),
|
||||
fullyMatched: Boolean(value.fullyMatched),
|
||||
expectedQuantity: count(value.expectedQuantity),
|
||||
receivedQuantity: count(value.receivedQuantity),
|
||||
signedAt: value.signedAt || null
|
||||
}
|
||||
}
|
||||
|
||||
export async function startReturnReceipt(returnNo) {
|
||||
return normalizeReturnReceiptStart(await overseasRequest('POST', `${BASE}/start`, { returnNo: String(returnNo || '').trim() }))
|
||||
}
|
||||
|
||||
export async function findReturnSkuCandidates(skuCode) {
|
||||
return normalizeReturnSkuCandidates(await overseasRequest('POST', `${BASE}/sku-candidates`, { skuCode: String(skuCode || '').trim() }))
|
||||
}
|
||||
|
||||
export async function confirmReturnReceipt(request) {
|
||||
return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request))
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export const MODULES = [
|
||||
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', descriptionKey: 'modules.inbound.description', operations: [
|
||||
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true }
|
||||
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
|
||||
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
|
||||
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
|
||||
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' }
|
||||
] },
|
||||
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
|
||||
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/move/move", "style": { "navigationStyle": "custom", "navigationBarTitleText": "商品移库", "backgroundColor": "#F5F7FA" } },
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<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 class="work-heading"><text class="work-title">退货合箱</text><text class="work-subtitle">仅可合并当前仓、同一客户、已完成签收的退货单库存</text></view>
|
||||
<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>
|
||||
|
||||
<template v-if="selectedOrders.length && !result">
|
||||
<view class="work-card"><text class="card-title">已选退货单</text><view class="selected-tags"><button v-for="item in selectedOrders" :key="item.returnNo" class="selected-tag" @tap="removeOrder(item.returnNo)">{{ item.returnNo }} ×</button></view><text class="hint">不同客户的退货单不能合箱;选择时会自动校验。</text></view>
|
||||
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">可用 {{ item.availableQuantity }}</text></view><view class="quantity-row"><text>装入数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
|
||||
<view class="work-card"><text class="card-title">新箱箱规</text><view class="dimension-grid"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" :value="packing[field.key]" :placeholder="field.placeholder" @input="setDimension(field.key, $event.detail.value)" /></label></view></view>
|
||||
<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="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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
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'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, candidates: null, previews: {}, result: null, busy: false, error: null, dimensionFields: [{ key: 'length', label: '长 cm', placeholder: '长' }, { key: 'width', label: '宽 cm', placeholder: '宽' }, { key: 'height', label: '高 cm', placeholder: '高' }, { key: 'weight', label: '重量 kg', placeholder: '重量' }] } },
|
||||
computed: {
|
||||
packing() { return warehouseWork.returnPacking },
|
||||
searchCode: { get() { return this.packing.searchCode }, set(value) { updateWarehouseWork('returnPacking', { searchCode: value }) } },
|
||||
selectedOrders() { return Array.isArray(this.packing.selectedOrders) ? this.packing.selectedOrders : [] },
|
||||
availableSkus() { const result = new Map(); for (const order of this.selectedOrders) for (const item of this.previews[order.returnNo]?.skus || []) { const current = result.get(item.skuCode) || { ...item, availableQuantity: 0 }; current.availableQuantity += Number(item.availableQuantity || 0); result.set(item.skuCode, current) } return [...result.values()] },
|
||||
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) }
|
||||
},
|
||||
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: {
|
||||
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) playErrorSound(); else playSuccessSound() }, '查询可合箱退货单失败') },
|
||||
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo); const exact = preview.orders.filter((order) => order.returnNo === item.returnNo); if (exact.length !== 1 || !preview.customerKey) throw new Error('请扫描完整退货单号后重试'); const customerKey = this.selectedOrders[0]?.customerKey; if (customerKey && customerKey !== preview.customerKey) throw new Error('不同客户的退货单不能合箱'); const selected = [...this.selectedOrders, { ...exact[0], customerKey: preview.customerKey }]; this.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; playSuccessSound() }, '添加退货单失败') },
|
||||
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
|
||||
validQuantities(selected, previews) { const available = new Map(); for (const order of selected) for (const item of previews[order.returnNo]?.skus || []) available.set(item.skuCode, (available.get(item.skuCode) || 0) + Number(item.availableQuantity || 0)); return Object.fromEntries([...available].map(([sku, maximum]) => [sku, Math.min(maximum, this.quantity(sku))])) },
|
||||
quantity(skuCode) { return Math.max(0, Number(this.packing.quantities?.[skuCode]) || 0) },
|
||||
setQuantity(item, raw) { const value = Math.min(item.availableQuantity, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnPacking', { quantities: { ...(this.packing.quantities || {}), [item.skuCode]: value } }) },
|
||||
setDimension(key, raw) { updateWarehouseWork('returnPacking', { [key]: String(raw || '').trim() }) },
|
||||
async refreshSelected() { if (this.busy || !this.selectedOrders.length) return; await this.run(async () => { const previews = {}; let customerKey = ''; for (const selected of this.selectedOrders) { const preview = await findPackingOrders(selected.returnNo); if (!preview.customerKey || (customerKey && preview.customerKey !== customerKey)) throw new Error('所选退货单已不可合箱,请重新选择'); customerKey = preview.customerKey; previews[selected.returnNo] = preview } this.previews = previews; updateWarehouseWork('returnPacking', { quantities: this.validQuantities(this.selectedOrders, previews) }) }, '刷新可用库存失败') },
|
||||
async mergeCartons() { if (!this.canMerge || this.busy) return; const payload = { returnNos: this.selectedOrders.map((item) => item.returnNo), items: this.availableSkus.map((item) => ({ skuCode: item.skuCode, quantity: this.quantity(item.skuCode) })).filter((item) => item.quantity > 0), length: Number(this.packing.length), width: Number(this.packing.width), height: Number(this.packing.height), weight: Number(this.packing.weight) }; const key = warehouseIdempotencyKey('returnPacking', 'merge', payload); await this.run(async () => { this.result = await mergeReturnCartons({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnPacking', key.signature); resetWarehouseDomain('returnPacking'); this.previews = {}; this.candidates = null; playSuccessSound() }, '退货合箱失败') },
|
||||
copyCartonMark() { const value = this.result?.label?.cartonMark || this.result?.cartonMark; if (value) uni.setClipboardData({ data: value }) },
|
||||
nextPacking() { this.result = null; this.error = null; this.searchCode = '' },
|
||||
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
sizeText(label) { return `${label.length || '—'} × ${label.width || '—'} × ${label.height || '—'} cm · ${label.weight || '—'} kg` }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../warehouse/operations.scss';
|
||||
.candidate-card{width:100%;text-align:left}.select-link,.remaining-copy{color:#135ee8;font-size:11px;font-weight:800}.selected-tags{display:flex;flex-wrap:wrap;gap:7px}.selected-tag{width:auto;height:32px;margin:0;padding:0 10px;border-radius:16px;color:#135ee8;font-size:10px;line-height:32px;background:#eaf2ff}.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}.dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.dimension-grid label>text{display:block;margin-bottom:5px;color:#65768b;font-size:10px}.dimension-grid .number-input{text-align:center}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<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 class="work-heading"><text class="work-title">退货入库</text><text class="work-subtitle">当前仓:{{ warehouseName }};每次提交生成一个签收箱和仓储计费批次</text></view>
|
||||
|
||||
<view v-if="!current && !result" class="work-card">
|
||||
<text class="card-title">选择查询方式</text>
|
||||
<view class="mode-row"><button class="mode-button" :class="{ active: entryMode === 'RETURN_NO' }" @tap="switchMode('RETURN_NO')">退货单号</button><button class="mode-button" :class="{ active: entryMode === 'SKU' }" @tap="switchMode('SKU')">SKU</button></view>
|
||||
<ScanTargetInput name="lookup" v-model="lookupCode" :label="entryMode === 'SKU' ? 'SKU' : '退货单号'" :placeholder="entryMode === 'SKU' ? '扫描或输入 SKU' : '扫描包裹上的退货单号'" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
|
||||
<button class="primary" :disabled="!lookupCode.trim() || busy" @tap="lookup(lookupCode)">{{ busy ? '查询中…' : '查询' }}</button>
|
||||
</view>
|
||||
|
||||
<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">当前绑定仓没有包含该 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>
|
||||
|
||||
<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>
|
||||
<text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" />
|
||||
<text class="field-label">箱号(选填)</text><ScanTargetInput name="carton" v-model="cartonMark" label="退货箱号" placeholder="扫描已有箱号,留空自动生成" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="acceptCarton" />
|
||||
</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 current.expectedItems" :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>
|
||||
</template>
|
||||
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货入库成功</text><text class="result-carton">{{ result.cartonMark }}</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>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { confirmReturnReceipt, findReturnSkuCandidates, startReturnReceipt } from '@/api/return-receipt.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, current: null, candidates: null, result: null, activeTarget: 'lookup', busy: false, error: null } },
|
||||
computed: {
|
||||
warehouseName() { return String(this.user?.warehouseName || '').trim() || '未关联海外仓' },
|
||||
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 }) } },
|
||||
cartonMark: { get() { return warehouseWork.returnReceipt.cartonMark }, set(value) { updateWarehouseWork('returnReceipt', { cartonMark: value }) } },
|
||||
receiptQuantity() { return Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
|
||||
canConfirm() { return this.current && this.receiptQuantity > 0 && this.current.expectedItems.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.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, warehouseWork.returnReceipt.startedBy, false) },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
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 === 'carton') this.acceptCarton(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; playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
|
||||
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; await this.openOrder(item.returnNo, 'SKU') },
|
||||
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { const value = await startReturnReceipt(returnNo); this.current = value; this.candidates = null; this.startedBy = startedBy; updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, quantities: this.validQuantities(value) }); this.activeTarget = 'tracking'; 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) },
|
||||
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 } }) },
|
||||
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.current.expectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
|
||||
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); this.activeTarget = 'carton'; playSuccessSound() },
|
||||
acceptCarton(raw) { this.cartonMark = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
|
||||
async confirmReceipt() { if (!this.canConfirm || this.busy) return; const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, cartonMark: this.cartonMark.trim() || undefined, items: this.current.expectedItems.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 reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
|
||||
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() },
|
||||
statusLabel(value) { return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '签收中', COMPLETED: '已完成' })[value] || value || '状态未知' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../warehouse/operations.scss';
|
||||
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#135ee8;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}
|
||||
</style>
|
||||
@@ -6,6 +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: { entryMode: 'RETURN_NO', lookupCode: '', currentReturnNo: '', startedBy: 'RETURN_NO', trackingNo: '', cartonMark: '', quantities: {}, pendingKeys: {} },
|
||||
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
|
||||
query: { inventoryCode: '', locationCode: '', palletCode: '' }
|
||||
})
|
||||
|
||||
@@ -35,7 +37,9 @@ export function restoreWarehouseWork(scopeKey) {
|
||||
Object.assign(warehouseWork, fresh, {
|
||||
scopeKey: expected,
|
||||
stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move),
|
||||
relabel: domain(saved.relabel, fresh.relabel), query: { ...fresh.query, ...(saved.query || {}) }
|
||||
relabel: domain(saved.relabel, fresh.relabel), returnReceipt: domain(saved.returnReceipt, fresh.returnReceipt),
|
||||
returnPacking: domain(saved.returnPacking, fresh.returnPacking),
|
||||
query: { ...fresh.query, ...(saved.query || {}) }
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
|
||||
'../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue',
|
||||
'../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue',
|
||||
'../src/pages/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.vue',
|
||||
'../src/pages/return-receipt/return-receipt.vue',
|
||||
'../src/pages/return-packing/return-packing.vue',
|
||||
'../src/pages/history/history.vue', '../src/pages/scanner-test/scanner-test.vue',
|
||||
'../src/components/PortalHeader.vue', '../src/components/PopupHeader.vue',
|
||||
'../src/components/ReceivingSessionSummary.vue', '../src/components/ScanTargetInput.vue'
|
||||
@@ -40,12 +42,14 @@ test('operator and warehouse cards never fall back to internal IDs', async () =>
|
||||
})
|
||||
|
||||
test('operation templates expose business references only and keep diagnostic evidence', async () => {
|
||||
const [operation, pallet, summary, stocktake, move, relabel, query, apiFiles] = await Promise.all([
|
||||
const [operation, pallet, summary, stocktake, move, relabel, query, returnReceipt, returnPacking, apiFiles] = await Promise.all([
|
||||
read('../src/pages/operation/operation.vue'),
|
||||
read('../src/pages/pallet/pallet.vue'),
|
||||
read('../src/components/ReceivingSessionSummary.vue'),
|
||||
read('../src/pages/stocktake/stocktake.vue'), read('../src/pages/move/move.vue'),
|
||||
read('../src/pages/relabel/relabel.vue'), read('../src/pages/query/query.vue'),
|
||||
read('../src/pages/return-receipt/return-receipt.vue'),
|
||||
read('../src/pages/return-packing/return-packing.vue'),
|
||||
Promise.all([
|
||||
read('../src/api/inbound.js'), read('../src/api/pallet.js'),
|
||||
read('../src/api/overseas.js'), read('../src/api/receiving-adapter.js'),
|
||||
@@ -65,13 +69,15 @@ test('operation templates expose business references only and keep diagnostic ev
|
||||
assert.doesNotMatch(template(pallet), new RegExp(`\\b${field}\\b`))
|
||||
for (const page of [stocktake, move, relabel, query]) assert.doesNotMatch(template(page), new RegExp(`\\b${field}\\b`))
|
||||
}
|
||||
for (const field of [...forbiddenDisplayFields, 'customerId']) assert.doesNotMatch(template(returnReceipt), new RegExp(`\\b${field}\\b`))
|
||||
for (const field of [...forbiddenDisplayFields, 'customerId']) assert.doesNotMatch(template(returnPacking), new RegExp(`\\b${field}\\b`))
|
||||
|
||||
for (const businessField of ['containerNo', 'inboundCode', 'cartonMark', 'palletCode']) {
|
||||
assert.match(operation + pallet + summary, new RegExp(businessField))
|
||||
}
|
||||
assert.match(operation, /requestId[\s\S]*traceId/)
|
||||
assert.match(pallet, /requestId[\s\S]*traceId/)
|
||||
for (const page of [stocktake, move, relabel, query]) assert.match(page, /requestId[\s\S]*traceId/)
|
||||
for (const page of [stocktake, move, relabel, query, returnReceipt, returnPacking]) assert.match(page, /requestId[\s\S]*traceId/)
|
||||
assert.doesNotMatch(apiFiles, /\/api\/platform\/v1\/|inbound-code-relationships/)
|
||||
})
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do
|
||||
const [data, session, runtime] = await Promise.all([
|
||||
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
|
||||
])
|
||||
for (const label of ['入仓作业', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
|
||||
for (const label of ['入仓作业', '拆柜收货', '退货入库', '退货合箱', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
|
||||
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
|
||||
assert.match(session, /overseas-pda-auth-session/)
|
||||
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
|
||||
@@ -145,6 +145,42 @@ test('Chinese English and Spanish locale selection is persisted from profile wit
|
||||
assert.match(inbound, /warehouse-profile/)
|
||||
})
|
||||
|
||||
test('return receipt is a real PDA flow backed by the dedicated return APIs', async () => {
|
||||
const [pages, workbench, page, api, warehouseWork] = await Promise.all([
|
||||
read('../src/pages.json'), read('../src/data/overseas-workbench.js'),
|
||||
read('../src/pages/return-receipt/return-receipt.vue'), read('../src/api/return-receipt.js'),
|
||||
read('../src/stores/warehouse-work.js')
|
||||
])
|
||||
assert.match(pages, /pages\/return-receipt\/return-receipt/)
|
||||
assert.match(workbench, /key: 'returnReceipt'[^\n]*available: true[^\n]*route: '\/pages\/return-receipt\/return-receipt'/)
|
||||
for (const action of ['startReturnReceipt', 'findReturnSkuCandidates', 'confirmReturnReceipt']) assert.match(page, new RegExp(action))
|
||||
for (const path of ['/start', '/sku-candidates', '/confirm']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
|
||||
assert.match(page, /startScanBroadcast/)
|
||||
assert.match(page, /startedBy: this\.startedBy/)
|
||||
assert.match(page, /idempotencyKey: key\.value/)
|
||||
assert.match(page, /requestId[\s\S]*traceId/)
|
||||
assert.match(warehouseWork, /returnReceipt:[^\n]*pendingKeys/)
|
||||
assert.doesNotMatch(page, /customerId/)
|
||||
})
|
||||
|
||||
test('return packing is a separate same-customer inventory operation', async () => {
|
||||
const [pages, workbench, page, api, warehouseWork] = await Promise.all([
|
||||
read('../src/pages.json'), read('../src/data/overseas-workbench.js'),
|
||||
read('../src/pages/return-packing/return-packing.vue'), read('../src/api/return-packing.js'),
|
||||
read('../src/stores/warehouse-work.js')
|
||||
])
|
||||
assert.match(pages, /pages\/return-packing\/return-packing/)
|
||||
assert.match(workbench, /key: 'returnPacking'[^\n]*available: true[^\n]*route: '\/pages\/return-packing\/return-packing'/)
|
||||
for (const action of ['findPackingOrders', 'mergeReturnCartons']) assert.match(page, new RegExp(action))
|
||||
for (const path of ['/packing/orders', '/cartons/merge', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
|
||||
assert.match(page, /startScanBroadcast/)
|
||||
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
|
||||
assert.match(page, /idempotencyKey: key\.value/)
|
||||
assert.match(page, /requestId[\s\S]*traceId/)
|
||||
assert.match(warehouseWork, /returnPacking:[^\n]*pendingKeys/)
|
||||
assert.doesNotMatch(page.match(/<template>([\s\S]*?)<\/template>/)?.[1] || '', /customerId|warehouseId/)
|
||||
})
|
||||
|
||||
test('home reuses the persisted warehouse profile instead of requesting it on every show', async () => {
|
||||
const [home, inbound, operation] = await Promise.all([read('../src/pages/home/home.vue'), read('../src/api/inbound.js'), read('../src/pages/operation/operation.vue')])
|
||||
assert.match(home, /if \(!this\.user\?\.warehouseCode \|\| !this\.user\?\.warehouseName\) this\.loadWarehouseProfile\(\)/)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
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', () => {
|
||||
const value = normalizePackingPreview({ customerId: '100', orders: [{ returnNo: 'RT-1', customerId: '100', customerReferenceNo: 'REF-1', status: 'COMPLETED' }], skus: [{ skuCode: 'SKU-1', productNameZh: '商品', availableQuantity: 8 }] })
|
||||
assert.equal(value.customerKey, '100')
|
||||
assert.equal(value.orders[0].returnNo, 'RT-1')
|
||||
assert.equal('customerId' in value.orders[0], false)
|
||||
assert.equal(value.skus[0].availableQuantity, 8)
|
||||
})
|
||||
|
||||
test('return packing API uses exact PDA inventory routes without submitting customer or warehouse scope', async () => {
|
||||
const calls = []
|
||||
session.apiBase = '/overseas-api'; session.token = 'packing-token'
|
||||
globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }); let data = {}; if (options.url.includes('/packing/orders')) data = { customerId: '100', orders: [], skus: [] }; else if (options.url.endsWith('/cartons/merge')) data = { cartonMark: 'RC-1', skuCode: 'SKU-1', totalQuantity: 2, label: { cartonMark: 'RC-1', items: [] } }; else data = { cartonMark: 'RC/1', totalQuantity: 2, items: [] }; options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } }
|
||||
await findPackingOrders('RT/1')
|
||||
const request = { returnNos: ['RT/1'], items: [{ skuCode: 'SKU-1', quantity: 2 }], length: 10, width: 20, height: 30, weight: 4, idempotencyKey: 'merge-key' }
|
||||
await mergeReturnCartons(request)
|
||||
await getReturnCartonLabel('RC/1')
|
||||
assert.deepEqual(calls.map((item) => item.url), [
|
||||
'/overseas-api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1',
|
||||
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label'
|
||||
])
|
||||
assert.deepEqual(calls[1].data, request)
|
||||
assert.ok(calls.every((item) => item.authorization === 'Bearer packing-token'))
|
||||
assert.ok(calls.every((item) => !Object.hasOwn(item.data || {}, 'customerId') && !Object.hasOwn(item.data || {}, 'warehouseId')))
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { confirmReturnReceipt, findReturnSkuCandidates, normalizeReturnReceiptResult, normalizeReturnReceiptStart, normalizeReturnSkuCandidates, startReturnReceipt } from '../src/api/return-receipt.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
test('return receipt start normalization keeps only stable business facts', () => {
|
||||
assert.deepEqual(normalizeReturnReceiptStart({ returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: '5', receivedQuantity: 2 }] }), {
|
||||
returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: 5, receivedQuantity: 2 }]
|
||||
})
|
||||
})
|
||||
|
||||
test('SKU candidates do not expose the backend customer identifier to the page', () => {
|
||||
const value = normalizeReturnSkuCandidates({ skuCode: 'SKU-1', candidates: [{ returnNo: 'RT-001', customerId: '100', customerReferenceNo: 'REF-1', status: 'PARTIALLY_RECEIVED', skuExpectedQuantity: 5, skuReceivedQuantity: 1, orderExpectedQuantity: 8, orderReceivedQuantity: 2 }] })
|
||||
assert.equal(value.candidates[0].returnNo, 'RT-001')
|
||||
assert.equal(value.candidates[0].customerReferenceNo, 'REF-1')
|
||||
assert.equal('customerId' in value.candidates[0], false)
|
||||
})
|
||||
|
||||
test('confirmed receipt normalization preserves generated carton and batch references', () => {
|
||||
assert.deepEqual(normalizeReturnReceiptResult({ returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }), {
|
||||
returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00'
|
||||
})
|
||||
})
|
||||
|
||||
test('return receipt API calls only the dedicated PDA routes and preserves the receipt payload', async () => {
|
||||
const calls = []
|
||||
session.apiBase = '/overseas-api'; session.token = 'return-token'
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
|
||||
let data
|
||||
if (options.url.endsWith('/start')) data = { returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [] }
|
||||
else if (options.url.endsWith('/sku-candidates')) data = { skuCode: 'SKU-1', candidates: [] }
|
||||
else data = { returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'PARTIALLY_RECEIVED', fullyMatched: false, expectedQuantity: 5, receivedQuantity: 2 }
|
||||
options.success({ statusCode: 200, data: { code: '200', data }, header: {} })
|
||||
}
|
||||
}
|
||||
await startReturnReceipt(' RT-001 ')
|
||||
await findReturnSkuCandidates(' SKU-1 ')
|
||||
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', cartonMark: 'RC-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
|
||||
await confirmReturnReceipt(confirm)
|
||||
assert.deepEqual(calls.map((item) => item.url), [
|
||||
'/overseas-api/pda/v1/last-leg/return-receipts/start',
|
||||
'/overseas-api/pda/v1/last-leg/return-receipts/sku-candidates',
|
||||
'/overseas-api/pda/v1/last-leg/return-receipts/confirm'
|
||||
])
|
||||
assert.deepEqual(calls[0].data, { returnNo: 'RT-001' })
|
||||
assert.deepEqual(calls[1].data, { skuCode: 'SKU-1' })
|
||||
assert.deepEqual(calls[2].data, confirm)
|
||||
assert.ok(calls.every((item) => item.authorization === 'Bearer return-token'))
|
||||
assert.ok(calls.every((item) => !Object.hasOwn(item.data || {}, 'customerId') && !Object.hasOwn(item.data || {}, 'warehouseId')))
|
||||
})
|
||||
@@ -7,6 +7,8 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
const enabled = operations.filter((operation) => operation.available)
|
||||
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
|
||||
{ module: 'inbound', name: '拆柜收货' },
|
||||
{ module: 'inbound', name: '退货入库' },
|
||||
{ module: 'inbound', name: '退货合箱' },
|
||||
{ module: 'outbound', name: '排车拣货' },
|
||||
{ module: 'outbound', name: '出库作业' },
|
||||
{ module: 'inventory', name: '库存盘点' },
|
||||
@@ -22,6 +24,8 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
|
||||
assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch')
|
||||
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
|
||||
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
|
||||
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
|
||||
|
||||
for (const name of ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
|
||||
const operation = operations.find((item) => item.name === name)
|
||||
|
||||
Reference in New Issue
Block a user