diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 081a4bd..3a78ddb 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -28,6 +28,7 @@ export interface RelabelDetail { export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string } export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface RelabelInventoryCandidate { + stockQuantity?: number; inventoryStatus?: string; occupiedRelabelNo?: string; mergedIntoCartonMarks?: string[]; hasOutbound?: boolean inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string overseasWarehouseId: string; overseasWarehouseCode?: string; overseasWarehouseName?: string destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index dd71e04..915bf02 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -199,13 +199,15 @@ const selectedTransferSourceOrderIds = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') const selectedReturnCandidates = computed(() => relabelCandidates.value.filter(item => Boolean(item.returnCartonId) && returnSourceNos(item).includes(selectedReturnOrderNo.value))) -const returnBoxCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) -const returnLooseCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) +const availableReturnCandidates = computed(() => selectedReturnCandidates.value.filter(item => inventoryQuantity(item.availablePackages) > 0).map(item => ({ ...item, skuItems: (item.skuItems || []).filter(sku => inventoryQuantity(sku.quantity) > 0) }))) +const returnBoxCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) +const returnLooseCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && labelingMode.value === 'carton_and_sku') function inventoryQuantity(value: number | string | null | undefined) { const quantity = Number(value) return Number.isFinite(quantity) && quantity > 0 ? quantity : 0 } +const relabelInventorySearch = ref('') function returnSourceNos(candidate: RelabelInventoryCandidate): string[] { return [...new Set((candidate.inboundOrderNo || '').split(',').map(value => value.trim()).filter(Boolean))] } @@ -213,24 +215,40 @@ function returnRelabelProgress(candidate: RelabelInventoryCandidate) { const labels: Record = { UNLABELLED: '未换标', BOUND: '处理中', PROCESSING: '处理中', PARTIALLY_COMPLETED: '部分完成', COMPLETED: '全部完成' } return [labels[candidate.relabelStatus || ''] || '—', '实际完成 ' + (candidate.relabelledQuantity ?? '—') + ' 件', '处理中占用 ' + (candidate.relabelReservedQuantity ?? '—') + ' 件'].join('|') } +function returnInventoryState(candidate: RelabelInventoryCandidate) { + const labels: Record = { AVAILABLE: '可选库存', RELABEL_PENDING: '换标占用', PACKING_RESERVED: '合箱计划预留', OUTBOUND_PENDING: '出库占用', DISCARD_PENDING: '废弃申请占用', OUTBOUNDED: '已出库', MERGED: '已合箱' } + return [...(candidate.mergedIntoCartonMarks?.length ? ['历史部分转入 ' + candidate.mergedIntoCartonMarks.join('、') + '(不代表当前库存位置)'] : []), labels[candidate.inventoryStatus || ''] || (inventoryQuantity(candidate.availablePackages) > 0 ? '可选库存' : '当前不可选'), candidate.occupiedRelabelNo].filter(Boolean).join(' · ') +} const relabelInboundOptions = computed(() => { - const grouped = new Map() + const grouped = new Map() for (const candidate of relabelCandidates.value) { const sources = candidate.returnCartonId ? returnSourceNos(candidate) : [String(candidate.inboundOrderId)] for (const sourceNo of sources) { const value = candidate.returnCartonId ? 'RETURN_ORDER:' + sourceNo : sourceNo const current = grouped.get(value) - if (current) current.availablePackages += inventoryQuantity(candidate.availablePackages) + if (current) { + current.hasOutbound ||= candidate.hasOutbound === true || candidate.inventoryStatus === 'OUTBOUNDED' + current.availablePackages += inventoryQuantity(candidate.availablePackages) + current.units += candidate.returnCartonId && candidate.stockQuantity === 0 ? 0 : 1 + current.stockQuantity = current.stockQuantity == null || candidate.stockQuantity == null ? null : current.stockQuantity + inventoryQuantity(candidate.stockQuantity) + if (candidate.returnCartonId) current.reasons.push(returnInventoryState(candidate)) + } else grouped.set(value, { value, inboundOrderNo: candidate.returnCartonId ? '退货单 ' + sourceNo : (candidate.inboundOrderNo || '—'), inboundCode: candidate.returnCartonId ? '已签收退货库存(含合箱)' : (candidate.inboundCode || '—'), availablePackages: inventoryQuantity(candidate.availablePackages), quantityUnit: candidate.returnCartonId ? '件' : '箱', + stockQuantity: candidate.stockQuantity == null ? null : inventoryQuantity(candidate.stockQuantity), units: candidate.returnCartonId && candidate.stockQuantity === 0 ? 0 : 1, hasOutbound: candidate.hasOutbound === true || candidate.inventoryStatus === 'OUTBOUNDED', reasons: candidate.returnCartonId ? [returnInventoryState(candidate)] : [], }) } } - return [...grouped.values()] + return [...grouped.values()].map(option => ({ ...option, reasons: [...new Set(option.reasons)] })) }) +const visibleRelabelInboundOptions = computed(() => relabelInboundOptions.value.filter(option => option.availablePackages > 0)) +function relabelOptionLabel(option: typeof relabelInboundOptions.value[number]) { + if (option.quantityUnit === '箱') return option.inboundOrderNo + '|' + option.inboundCode + '|可选 ' + option.availablePackages + ' 箱' + return option.inboundOrderNo + '|在仓 ' + option.units + ' 包裹/箱 · ' + (option.stockQuantity ?? '待查询') + ' 件' + (option.hasOutbound && Number(option.stockQuantity) > 0 ? '|部分出库' : '') +} const loading = ref(false) const saving = ref(false) const cartonTemplateInput = ref(null) @@ -1480,7 +1498,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) { - +
*入仓方式仓库上门提货客户送货到仓
@@ -1548,11 +1566,11 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
diff --git a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs index 070cdc1..f789374 100644 --- a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs +++ b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs @@ -40,9 +40,9 @@ test('贴标申请移除操作项下方的解释文字,保留必要状态', () test('贴标申请可从当前客户仍有库存的入仓单或退货单选择来源', () => { assert.match(view, /海外操作仓(中转仓)/) - assert.match(view, /选择有库存的入仓单或退货单/) + assert.match(view, /选择入仓单或退货单/) assert.match(view, /quantityUnit: candidate\.returnCartonId \? '件' : '箱'/) - assert.match(view, /当前中转仓暂无可用库存/) + assert.match(view, /暂无匹配的可选库存/) assert.match(view, /relabelInboundOptions/) assert.match(view, /listRelabelInventoryCandidates\(form\.warehouse\)/) assert.match(api, /relabel-applications\/inventory-candidates/) diff --git a/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs new file mode 100644 index 0000000..f954180 --- /dev/null +++ b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') +const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const loading = ref(false)')) +function setup(candidates) { + const output = ts.transpileModule(source + '\nexports.options = relabelInboundOptions; exports.visible = visibleRelabelInboundOptions; exports.label = relabelOptionLabel', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText + const exports = {}, show = { value: false }, search = { value: '' }, selected = { value: '' } + new Function('exports', 'computed', 'relabelCandidates', 'showOccupiedRelabelInventory', 'relabelInventorySearch', 'selectedRelabelInboundOrderId', 'inventoryQuantity', output)(exports, fn => ({ get value() { return fn() } }), { value: candidates }, show, search, selected, value => Math.max(0, Number(value) || 0)) + return { ...exports, show, search, selected } +} +test('occupied return keeps stock facts but remains hidden when searched', () => { + const context=setup([{ returnCartonId:'C', inboundOrderNo:'RT-1', availablePackages:0, stockQuantity:10, inventoryStatus:'RELABEL_PENDING', occupiedRelabelNo:'RL-1' }]) + assert.equal(context.visible.value.length,0) + context.search.value='RT-1'; assert.equal(context.visible.value.length,0) + const label=context.label(context.options.value[0]); assert.match(label,/在仓 1 包裹\/箱 · 10 件/); assert.equal(label,'退货单 RT-1|在仓 1 包裹/箱 · 10 件'); assert.match(context.options.value[0].reasons.join(';'),/换标占用 · RL-1/) + context.search.value=''; assert.equal(context.visible.value.length,0) +}) +test('merged empty source adds history reason without inflating physical stock count', () => { + const context=setup([{ returnCartonId:'OLD', inboundOrderNo:'RT-1', availablePackages:0, stockQuantity:0, inventoryStatus:'MERGED', mergedIntoCartonMarks:['NEW'] },{ returnCartonId:'NEW', inboundOrderNo:'RT-1', availablePackages:10, stockQuantity:10, inventoryStatus:'AVAILABLE' }]) + assert.equal(context.options.value[0].units,1); assert.equal(context.options.value[0].stockQuantity,10); assert.equal(context.options.value[0].availablePackages,10) + assert.match(context.options.value[0].reasons.join(';'),/历史部分转入 NEW(不代表当前库存位置)/) +}) +test('missing stock projection stays unknown and outbound history does not count in-warehouse units', () => { + const context=setup([{returnCartonId:'C',inboundOrderNo:'RT-1',availablePackages:0},{returnCartonId:'D',inboundOrderNo:'RT-2',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}]) + assert.match(context.label(context.options.value[0]),/待查询/) + assert.equal(context.options.value[1].units,0);assert.match(context.options.value[1].reasons.join(';'),/已出库/) +}) + +test('fully outbound orders stay hidden even in history search; mixed remaining stock marks partial outbound', () => { + const context=setup([{returnCartonId:'D',inboundOrderNo:'RT-D',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}, {returnCartonId:'A',inboundOrderNo:'RT-P',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}, {returnCartonId:'B',inboundOrderNo:'RT-P',availablePackages:2,stockQuantity:2,inventoryStatus:'AVAILABLE'}]) + context.show.value=true + assert.deepEqual(context.visible.value.map(item=>item.inboundOrderNo),['退货单 RT-P']) + context.search.value='RT-D';assert.equal(context.visible.value.some(item=>item.inboundOrderNo==='退货单 RT-D'),false) + assert.equal(context.label(context.options.value[1]),'退货单 RT-P|在仓 1 包裹/箱 · 2 件|部分出库') +}) + +test('partially shipped contents within one physical box show partial outbound', () => { + const context=setup([{returnCartonId:'B',inboundOrderNo:'RT-P',availablePackages:3,stockQuantity:3,inventoryStatus:'AVAILABLE',hasOutbound:true}]) + assert.match(context.label(context.options.value[0]),/3 件|部分出库$/) +}) + +test('available box and parcel lists remove zero-quantity sources and zero SKU rows', () => { + const block=view.slice(view.indexOf('const availableReturnCandidates'),view.indexOf('const requiresBothTotalFiles')) + const output=ts.transpileModule(block+'\nexports.boxes=returnBoxCandidates; exports.parcels=returnLooseCandidates',{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText + const exports={} + const sources=[{sourceType:'RETURN_CARTON',cartonMark:'EMPTY',availablePackages:0,skuItems:[{skuCode:'A',quantity:0}]},{sourceType:'RETURN_CARTON',cartonMark:'BOX',availablePackages:3,skuItems:[{skuCode:'A',quantity:0},{skuCode:'B',quantity:3}]},{sourceType:'RETURN_PARCEL',cartonMark:'P0',availablePackages:0},{sourceType:'RETURN_PARCEL',cartonMark:'P1',availablePackages:2,skuItems:[{skuCode:'C',quantity:2}]}] + new Function('exports','computed','selectedReturnCandidates','inventoryQuantity',output)(exports,fn=>({get value(){return fn()}}),{value:sources},value=>Math.max(0,Number(value)||0)) + assert.deepEqual(exports.boxes.value.map(item=>item.cartonMark),['BOX']) + assert.deepEqual(exports.boxes.value[0].skuItems,[{skuCode:'B',quantity:3}]) + assert.deepEqual(exports.parcels.value.map(item=>item.cartonMark),['P1']) + assert.doesNotMatch(view,/显示占用及历史单据|showOccupiedRelabelInventory/) +})