发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #15

Merged
admin merged 69 commits from release/test into main 2026-10-23 10:51:31 +08:00
12 changed files with 212 additions and 25 deletions
Showing only changes of commit 64697276ec - Show all commits
@@ -1,12 +1,19 @@
import { apiRequest } from '@/api/http'
export interface RelabelQuantity {
sourcePalletCount?: number | string | null
targetPalletCount?: number | string | null
sourceUnitCounts?: Record<string, number | string> | null
labelPieceCount: number | string | null
cartonCount: number | string | null
skuCount: number | string
duplicateCarton: boolean
}
export interface RelabelQuantityTotals {
knownSourcePalletCount?: number | string
unknownSourcePalletOrders?: number | string
knownTargetPalletCount?: number | string
unknownTargetPalletOrders?: number | string
orderCount: number | string
skuCount: number | string
knownLabelPieceCount: number | string
@@ -2,6 +2,10 @@ import { apiRequest, apiBlobRequest } from '@/api/http'
import type { PageResult } from './warehouseServices'
export interface ReturnOrderSummary {
remainingQuantity?: number
receivedBoxCount?: number
receivedParcelCount?: number
receivedPalletCount?: number
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; quantityUnit?: string; createdAt: string }>
returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string
skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; createdAt: string
@@ -30,6 +34,13 @@ export interface ReturnOrderCreate {
export function listReturnOrders(params: Record<string, unknown>) {
return apiRequest<PageResult<ReturnOrderSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params })
}
export interface ReturnOrderStatistics {
totals: { orderCount: number; skuTypes: number; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }
pageDimensions: Array<{ returnNo: string; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }>
}
export function getReturnOrderStatistics(params: Record<string, unknown>) {
return apiRequest<ReturnOrderStatistics>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders/statistics', params })
}
export function getReturnOrder(returnNo: string) {
return apiRequest<ReturnOrderDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` })
}
@@ -0,0 +1,23 @@
import { apiRequest } from '@/api/http'
export interface TransferQuantityTotals {
orderCount: number | string
declaredPieceCount: number | string | null
knownDeclaredPieceCount: number | string
unknownDeclaredOrderCount: number | string
receivedPieceCount: number | string | null
knownReceivedPieceCount: number | string
unknownReceivedOrderCount: number | string
declaredCartonCount: number | string
receivedCartonCount: number | string
receivedPalletCount: number | string
availableCartonCount: number | string
shippedCartonCount: number | string
remainingPalletCount: number | string
shippedPalletCount: number | string
}
/** 客户范围由会话决定,汇总使用本次列表完整筛选。 */
export function getTransferQuantities(params: Record<string, unknown>) {
return apiRequest<TransferQuantityTotals>({ method: 'GET', url: '/merchant/v1/last-leg/warehouse-transfers/quantities', params })
}
@@ -25,8 +25,8 @@ export interface RelabelDetail {
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string }
}
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; customerConfirmedAt?: string }
export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
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; customerConfirmedAt?: string; skuCodes: string[]; skuCount: number; requestedPackages: number; actualPackages: number | null }
export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number; availablePieces?: number | null }> }
export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; 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 {
cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string
@@ -26,6 +26,8 @@ watch(() => [props.query, props.relabelNo], async () => {
<span>{{ data.orderCount }} 票</span><span>{{ data.skuCount }} 种 SKU</span>
<span>贴标件数:{{ dimension(Number(data.knownLabelPieceCount), Number(data.unknownPieceOrders), '件') }}</span>
<span>箱数:{{ dimension(Number(data.knownCartonCount), Number(data.unknownCartonOrders), '箱') }}</span>
<span>原托盘:{{ dimension(Number(data.knownSourcePalletCount ?? 0), Number(data.unknownSourcePalletOrders ?? data.orderCount), '托') }}</span>
<span>本轮目标托盘:{{ dimension(Number(data.knownTargetPalletCount ?? 0), Number(data.unknownTargetPalletOrders ?? data.orderCount), '托') }}</span>
<p v-if="relabelNo && data.orders[relabelNo]?.duplicateCarton" role="status">存在重复箱唛关联,箱数已去重,贴标件数待核查。</p>
</template>
<span v-else>数量加载中…</span>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getTransferQuantities, type TransferQuantityTotals } from '../api/transferQuantities'
const props = defineProps<{ query: Record<string, unknown> }>()
const data = ref<TransferQuantityTotals>()
const error = ref('')
let generation = 0
watch(() => JSON.stringify(props.query), async () => {
const current = ++generation
data.value = undefined; error.value = ''
try { const result = await getTransferQuantities(props.query); if (current === generation) data.value = result }
catch { if (current === generation) error.value = '转运数量汇总加载失败,请重新查询' }
}, { immediate: true })
</script>
<template>
<section class="transfer-quantity-panel" aria-label="仓储转运全筛选汇总">
<p v-if="error" role="alert">{{ error }}</p>
<template v-else-if="data">
<strong>全部筛选结果 · {{ data.orderCount }} 票</strong>
<span>预报:{{ data.declaredPieceCount ?? `未记录(已知 ${data.knownDeclaredPieceCount} 件,${data.unknownDeclaredOrderCount} 票未知)` }}{{ data.declaredPieceCount == null ? '' : ' 件' }} / {{ data.declaredCartonCount }} 箱</span>
<span>历史实收:{{ data.receivedPieceCount ?? `未记录(已知 ${data.knownReceivedPieceCount} 件,${data.unknownReceivedOrderCount} 票未知)` }}{{ data.receivedPieceCount == null ? '' : ' 件' }} / {{ data.receivedCartonCount }} 箱 / {{ data.receivedPalletCount }} 托</span>
<span>当前已清点 {{ data.availableCartonCount }} 箱 · 剩余 {{ data.remainingPalletCount }} 托 · 已出库 {{ data.shippedCartonCount }} 箱 / {{ data.shippedPalletCount }} 托(托盘可重叠)</span>
</template>
<span v-else>转运数量加载中…</span>
</section>
</template>
<style scoped>
.transfer-quantity-panel { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px 16px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, white); }
.transfer-quantity-panel p { margin: 0; }
</style>
@@ -49,3 +49,14 @@ test('相同仓位重复关联只计一次,零值与未知可用量不进入
assert.equal(groupDiscardDocuments([source, source])[0].availablePackages, 2)
for (const availablePackages of [0, null, undefined, 'invalid']) assert.equal(groupDiscardDocuments([{ ...source, availablePackages }]).length, 0)
})
test('相同来源的混箱分片保留全部SKU和实箱,且不修改接口原始对象', () => {
const a = { ...returned('1'), sourceType: 'INVENTORY', inventoryPositionId: 'P', skus: [{ skuCode: 'A', availablePackages: 1 }] }
const b = { ...a, skus: [{ skuCode: 'B', availablePackages: 1 }], cartons: [{ ...a.cartons[0], skus: [{ skuCode: 'B', availablePackages: 1 }] }] }
const doc = groupDiscardDocuments([a, b])[0]
assert.equal(doc.availablePackages, 1)
assert.equal(doc.sources.length, 1)
assert.deepEqual(doc.sources[0].skus.map(sku => sku.skuCode), ['A', 'B'])
assert.deepEqual(doc.sources[0].cartons[0].skus.map(sku => sku.skuCode), ['A', 'B'])
assert.equal(a.skus.length, 1)
})
@@ -7,15 +7,28 @@ export interface DiscardDocument extends DiscardCandidate {
/** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */
export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] {
const groups = new Map<string, DiscardDocument>()
const identities = new Set<string>()
const identities = new Map<string, DiscardCandidate>()
for (const candidate of candidates) {
const available = Number(candidate.availablePackages)
if (candidate.availablePackages == null || !Number.isFinite(available) || available <= 0) continue
// 按真实来源身份去重;缺身份的旧接口行仍独立保留,不合并不同库存。
const identity = candidate.sourceType === 'RETURN_CARTON' ? candidate.returnCartonId : candidate.inventoryPositionId || candidate.orderId
const sourceKey = identity ? JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, identity]) : null
if (sourceKey && identities.has(sourceKey)) continue
if (sourceKey) identities.add(sourceKey)
const existing = sourceKey ? identities.get(sourceKey) : undefined
if (existing) {
// 同一仓位的分片响应合并 SKU 和箱,重复关联不会丢掉箱内商品。
existing.skus = [...new Map([...existing.skus, ...candidate.skus].map(sku => [sku.skuCode, sku])).values()]
const cartons = new Map((existing.cartons || []).map(box => [box.cartonId, box]))
for (const box of candidate.cartons || []) {
const old = cartons.get(box.cartonId)
cartons.set(box.cartonId, old ? { ...old, skus: [...new Map([...old.skus, ...box.skus].map(sku => [sku.skuCode, sku])).values()] } : { ...box })
}
existing.cartons = [...cartons.values()]
existing.availablePackages = Math.max(Number(existing.availablePackages), available)
continue
}
const source = { ...candidate, skus: [...candidate.skus], cartons: candidate.cartons?.map(box => ({ ...box, skus: [...box.skus] })) }
if (sourceKey) identities.set(sourceKey, source)
const documentNo = candidate.inboundOrderNo || candidate.inboundCode
const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId])
let document = groups.get(key)
@@ -23,7 +36,7 @@ export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDo
document = { ...candidate, orderId: key, sources: [], availablePackages: 0 }
groups.set(key, document)
}
document.sources.push(candidate)
document.sources.push(source)
}
for (const document of groups.values()) {
const boxes = new Set<string>()
@@ -6,6 +6,45 @@ import ts from 'typescript'
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
function listHarness(list, summary) {
const state = { query: { page: 1, size: 1, skuCode: 'FILTER' }, loading: { value: false }, listError: { value: '' }, statistics: { value: undefined }, rows: { value: [] }, total: { value: 0 } }
const source = view.slice(view.indexOf('async function load()'), view.indexOf('\nfunction changePage('))
const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const load = new Function(...Object.keys(state), 'listReturnOrders', 'getReturnOrderStatistics', `let listRequest=0; ${js}; return load`)(...Object.values(state), list, summary)
return { ...state, load }
}
test('全筛选统计使用服务器总计,分页行只按单号映射载体,不重算SKU', async () => {
const calls = []
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5 }
const h = listHarness(async p => { calls.push(p); return { total: '3', records: [{ returnNo: 'RT-1', receivedQuantity: 8 }] } }, async p => { calls.push(p); return { totals, pageDimensions: [{ returnNo: 'RT-1', receivedParcelCount: 1, receivedBoxCount: 1 }] } })
await h.load()
assert.deepEqual(h.statistics.value, totals)
assert.equal(h.rows.value[0].receivedParcelCount, 1)
h.query.page = 2
await h.load()
assert.equal(h.statistics.value.receivedQuantity, 14)
assert.deepEqual(calls[2], calls[3])
assert.equal(calls[3].page, 2)
})
test('统计失败清空旧列表和汇总,并忽略较旧查询的迟到结果', async () => {
let fail = false, finish
const h = listHarness(async () => ({ total: 1, records: [{ returnNo: 'RT-1' }] }), async p => {
if (fail) throw Error('统计服务不可用')
if (p.page === 1) return new Promise(resolve => { finish = resolve })
return { totals: { orderCount: 2 }, pageDimensions: [] }
})
const old = h.load(); h.query.page = 2; await h.load()
finish({ totals: { orderCount: 999 }, pageDimensions: [] }); await old
assert.equal(h.statistics.value.orderCount, 2)
fail = true; await h.load()
assert.equal(h.statistics.value, undefined)
assert.deepEqual(h.rows.value, [])
assert.equal(h.listError.value, '统计服务不可用')
assert.equal(h.loading.value, false)
})
function submitHarness(failure) {
const source = view.slice(view.indexOf('async function submit()'), view.indexOf('\nfunction statusType('))
const state = { requests: [], loads: 0, routes: [], errors: [], successes: [] }
@@ -11,6 +11,7 @@ import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-l
import { createIdempotencyKey } from '@/utils/idempotencyKey'
import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
import ReviewRejectionNotice from '../components/ReviewRejectionNotice.vue'
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/returnOrders'
import { getReturnInventoryLabel } from '../api/returnInventory'
import { formatReturnVolume, receiptBoxDetails, receiptPhysicalDetails, receiptPrintableBoxCount, receiptTrackingNo } from '../utils/returnReceiptDisplay'
@@ -40,6 +41,9 @@ const printableBoxCount = computed(() => receiptPrintableBoxCount(printableRecei
const photoUrls = reactive<Record<string, string>>({})
const rows = ref<ReturnOrderSummary[]>([])
const total = ref(0)
const statistics = ref<ReturnOrderStatistics['totals']>()
const listError = ref('')
let listRequest = 0
const operationWarehouses = ref<MerchantWarehouse[]>([])
const operationWarehousesLoading = ref(false)
const operationWarehousesError = ref('')
@@ -62,8 +66,13 @@ const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 220, fixed: 'left' },
{ prop: 'returnNo', label: '退货单号', minWidth: 190 }, { prop: 'customerReferenceNo', label: '客户参考号', minWidth: 160 },
{ key: 'warehouse', label: '操作仓', slotName: 'warehouse', minWidth: 200 }, { key: 'relabel', label: '换标', slotName: 'relabel', width: 100 },
{ prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报数量', width: 110 },
{ prop: 'receivedQuantity', label: '实收数量', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 150 },
{ prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报(件)', width: 110 },
{ prop: 'receivedQuantity', label: '实收(件)', width: 110 },
{ prop: 'remainingQuantity', label: '待收(件)', width: 110 },
{ prop: 'receivedBoxCount', label: '签收箱数', width: 110 },
{ prop: 'receivedParcelCount', label: '签收包裹数', width: 115 },
{ prop: 'receivedPalletCount', label: '签收托数', width: 110 },
{ key: 'status', label: '状态', slotName: 'status', width: 150 },
{ key: 'relabelApplications', label: '换标子单', slotName: 'relabelApplications', minWidth: 340 },
{ key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 },
]
@@ -147,9 +156,21 @@ async function openEdit(returnNo: string) {
function addItem() { form.items.push(emptySku()) }
function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) }
async function load() {
const request = ++listRequest
loading.value = true
try { const response = await listReturnOrders({ ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }); rows.value = response.records; total.value = Number(response.total); if (!Number.isFinite(total.value)) total.value = response.records.length }
finally { loading.value = false }
listError.value = ''; statistics.value = undefined
try {
const params = { ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }
const [response, summary] = await Promise.all([listReturnOrders(params), getReturnOrderStatistics(params)])
if (request !== listRequest) return
const dimensions = new Map(summary.pageDimensions.map(row => [row.returnNo, row]))
rows.value = response.records.map(row => ({ ...row, ...dimensions.get(row.returnNo) }))
total.value = Number(response.total); statistics.value = summary.totals
} catch (error) {
if (request !== listRequest) return
rows.value = []; total.value = 0
listError.value = error instanceof Error ? error.message : '退货列表及汇总加载失败'
} finally { if (request === listRequest) loading.value = false }
}
function changePage(page: number) {
if (page === query.page) return
@@ -323,6 +344,11 @@ onBeforeUnmount(clearPhotoUrls)
<template v-else-if="!props.detailPage">
<QueryToolbar v-model="queryModel" class="return-query" layout="grid" ariaLabel="退货签收筛选" :fields="queryFields" @search="search" />
<el-alert v-if="listError" type="error" :closable="false" :title="listError"><el-button @click="load">重新加载</el-button></el-alert>
<div v-if="statistics && !loading" class="return-filter-summary" role="status" aria-label="退货全筛选汇总">
全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuTypes }} 种 SKU · 预报 {{ statistics.expectedQuantity }} 件 · 实收 {{ statistics.receivedQuantity }} 件 · 待收 {{ statistics.remainingQuantity }} 件 · 签收 {{ statistics.receivedBoxCount }} 箱 / {{ statistics.receivedParcelCount }} 包裹 / {{ statistics.receivedPalletCount }} 托
<span>预报箱/包裹/托数:未提供</span>
</div>
<div class="return-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="rowActions" row-key="returnNo" empty-text="暂无退货预报">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></template>
@@ -364,7 +390,7 @@ onBeforeUnmount(clearPhotoUrls)
<section class="return-detail-section">
<h3>签收概览</h3>
<div class="return-detail-grid">
<div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div>
<div><span>预报(件)</span><strong>{{ selected.order.expectedQuantity }}</strong></div><div><span>实收(件)</span><strong>{{ selected.order.receivedQuantity }}</strong></div><div><span>待收(件)</span><strong>{{ Math.max(selected.order.expectedQuantity - selected.order.receivedQuantity, 0) }}</strong></div>
<div><span>SKU 种类</span><strong>{{ selected.order.skuTypes }}</strong></div>
<div><span>是否换标</span><strong>{{ selected.order.needRelabel ? '需要换标' : '否' }}</strong></div>
<div><span>客户参考号</span><strong>{{ selected.order.customerReferenceNo || '—' }}</strong></div>
@@ -475,6 +501,8 @@ onBeforeUnmount(clearPhotoUrls)
</template>
<style scoped>
.return-filter-summary { padding:12px 16px; margin-bottom:12px; border:1px solid var(--client-border); border-radius:8px; background:var(--client-surface,#fff); line-height:1.8; }
.return-filter-summary span { margin-left:16px; color:var(--client-text-secondary,#64748b); }
.return-query.query-toolbar--grid { display:grid; grid-template-columns:repeat(4,minmax(0,280px)) max-content; gap:14px 18px; align-items:end; }
.return-query :deep(.query-toolbar__filters) { display:contents; }
.return-query :deep(.query-toolbar__field) { display:flex; flex-direction:column; align-items:stretch; gap:6px; }
File diff suppressed because one or more lines are too long
@@ -28,7 +28,7 @@ test('废弃来源包含通用仓位,选择后带出全部库存且不把选
function selectionRows(boxes, selectedKeys) {
const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map<string, CargoRow>()', 'new Map()')
const rows = { value: [] }
new Function('discardSelectableBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({}))
new Function('discardSourceBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({}))
return rows.value
}
test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => {
@@ -48,3 +48,12 @@ test('混合SKU箱按实物箱只计一次,退货箱仍整箱携带全部SKU',
const returnRows = selectionRows([{ key: 'RETURN_CARTON:2', cartonId: '2', source: returned, skus }], ['RETURN_CARTON:2'])
assert.deepEqual(returnRows.map(row => row.quantity), [2, 3]); assert.ok(returnRows.every(row => row.returnCartonId === '2' && row.discardCartonIds === undefined))
})
test('通用混箱同仓位保留全部SKU,跨仓位相同SKU保持独立来源', () => {
const skus = [{ skuCode: 'A', availablePackages: 1 }, { skuCode: 'B', availablePackages: 1 }]
const source = { orderId: null, inventoryPositionId: '42', sourceType: 'INVENTORY', availablePackages: 1, skus }
const boxes = [{ key: 'INVENTORY:1', cartonId: '1', source, skus }, { key: 'INVENTORY:2', cartonId: '2', source: { ...source, inventoryPositionId: '43' }, skus: [skus[0]] }]
const rows = selectionRows(boxes, ['INVENTORY:1', 'INVENTORY:2'])
assert.equal(rows.length, 3)
assert.deepEqual(rows.map(row => [row.sourceInventoryPositionId, row.sku, row.quantity, row.discardCartonIds]), [['42', 'A', 1, ['1']], ['42', 'B', 1, ['1']], ['43', 'A', 1, ['2']]])
})