diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts index e6cda49..31b4ea2 100644 --- a/src/modules/overseas-warehouse/api/returnOrders.ts +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -16,7 +16,7 @@ export interface ReturnReceipt { labels?: Array<{ cartonMark: string; printableHtml: string }> labelRepairRequired?: boolean } -export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> } +export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; length?: number; width?: number; height?: number; weight?: number; volumeCbm?: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> } export interface ReturnPhoto { fileId: number; fileName: string; contentType: string; sizeBytes: number } export interface ReturnPackingDetail { sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[] } diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index c030e92..16c3bb7 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -33,7 +33,7 @@ export interface RelabelInventoryCandidate { destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string availablePackages: number | string; version: number positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }> - sourceType?: 'INBOUND' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string + sourceType?: 'INBOUND' | 'RETURN_CARTON' | 'RETURN_PARCEL'; returnCartonId?: string; cartonMark?: string skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>; relabelledPackages?: number } diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index 651ddc1..90e1872 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -5,6 +5,45 @@ import test from 'node:test' const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') +function submitHarness(failure) { + const source = view.slice(view.indexOf('async function submit()'), view.indexOf('\nfunction statusType(')) + const state = { requests: [], loads: 0, routes: [], errors: [], successes: [] } + const form = { overseasWarehouseId: 'WH-1', warehouseContactName: 'Contact', warehouseContactPhone: '123', customerReferenceNo: 'REF-NEW', remark: 'edited', needRelabel: true, items: [{ skuCode: 'SKU-NEW', productNameZh: 'Product', expectedQuantity: 25 }] } + const editingReturnNo = { value: 'RT-TEST' }, showCreate = { value: true }, creating = { value: false } + const submit = new Function('form', 'duplicateSkuIds', 'creating', 'editingReturnNo', 'showCreate', 'props', 'ElMessage', 'updateReturnOrder', 'createReturnOrder', 'createIdempotencyKey', 'resetForm', 'router', 'load', `${source}; return submit`)( + form, { value: new Set() }, creating, editingReturnNo, showCreate, { createPage: false, detailPage: false }, + { warning() {}, success(message) { state.successes.push(message) }, error(message) { state.errors.push(message) } }, + async (returnNo, payload) => { state.requests.push({ returnNo, payload }); if (failure) throw failure }, + async () => assert.fail('Editing must use update'), () => 'edit-test', () => {}, + { async push(route) { state.routes.push(route) } }, async () => { state.loads++ }, + ) + return { submit, state, editingReturnNo, showCreate, creating } +} + +test('编辑退货保存提交修改后的字段,并重新读取当前列表', async () => { + const { submit, state, editingReturnNo, showCreate, creating } = submitHarness() + await submit() + assert.equal(state.requests[0].returnNo, 'RT-TEST') + assert.equal(state.requests[0].payload.customerReferenceNo, 'REF-NEW') + assert.equal(state.requests[0].payload.items[0].expectedQuantity, 25) + assert.equal(state.loads, 1) + assert.deepEqual(state.routes, []) + assert.equal(showCreate.value, false) + assert.equal(editingReturnNo.value, '') + assert.equal(creating.value, false) +}) + +test('编辑退货保存失败显示接口错误并保留编辑内容供重试', async () => { + const { submit, state, editingReturnNo, showCreate, creating } = submitHarness(new Error('只有待审核退货预报可以修改')) + await submit() + assert.deepEqual(state.errors, ['只有待审核退货预报可以修改']) + assert.deepEqual(state.successes, []) + assert.equal(state.loads, 0) + assert.equal(showCreate.value, true) + assert.equal(editingReturnNo.value, 'RT-TEST') + assert.equal(creating.value, false) +}) + test('退货签收照片通过客户鉴权下载接口加载并释放对象 URL', () => { assert.match(api, /\/return-orders\/\$\{encodeURIComponent\(returnNo\)\}\/photos\/files\/\$\{encodeURIComponent\(String\(fileId\)\)\}\/download/) assert.match(api, /apiBlobRequest/) @@ -81,7 +120,7 @@ const labelHtml = mark => `${labelStyle} function printHarness() { const source = view.slice(view.indexOf('async function printReturnLabel('), view.indexOf('\nfunction openDetail(')) - .replace('receipt: ReturnReceipt', 'receipt') + .replace('receipt: ReturnReceipt | ReturnReceipt[]', 'receipt') const calls = { opened: 0, printed: 0, html: '', warnings: [], errors: [], fetched: [] } const popup = { document: { open() {}, write(html) { calls.html = html }, close() {} }, @@ -119,7 +158,7 @@ test('退货多箱打印保留统一模板尺寸和每箱条码,并按箱分 assert.equal(calls.printed, 1) assert.deepEqual(calls.fetched, []) assert.deepEqual(calls.errors, []) - assert.match(view, /打印全部箱唛(\{\{ receipt\.labels\?\.length \|\| receipt\.boxCount \|\| 1 \}\} 箱)/) + assert.match(view, /打印全部箱唛(\{\{ printableBoxCount \}\} 箱)/) }) test('缺少内嵌模板时读取鉴权箱唛模板,待修复箱记录禁止打印', async () => { @@ -136,6 +175,22 @@ test('缺少内嵌模板时读取鉴权箱唛模板,待修复箱记录禁止 assert.deepEqual(calls.errors, []) }) +test('两托各两箱合并为一次四页打印,每箱条码保留且模板样式不重复', async () => { + const { print, calls } = printHarness() + const receipts = [1, 2].map(pallet => ({ + cartonMark: `PALLET-${pallet}`, boxCount: 2, + labels: [1, 2].map(box => ({ cartonMark: `SKU-${pallet}#2-${box}`, printableHtml: labelHtml(`SKU-${pallet}#2-${box}`) })), + printableHtml: `batch-${pallet}`, + })) + await print(receipts) + assert.equal(calls.opened, 1) + assert.equal(calls.printed, 1) + assert.equal((calls.html.match(/
/g) || []).length, 4) + assert.equal(calls.html.split(labelStyle).length - 1, 1) + for (const receipt of receipts) for (const label of receipt.labels) assert.ok(calls.html.includes(`data-barcode="${label.cartonMark}"`)) + assert.deepEqual(calls.errors, []) +}) + test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => { assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/) assert.match(view, /if \(editingReturnNo\.value\) await updateReturnOrder\(editingReturnNo\.value, payload\)/) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 0d7535a..87de609 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -24,6 +24,8 @@ const creating = ref(false) const showCreate = ref(false) const editingReturnNo = ref('') const selected = ref() +const printableReceipts = computed(() => selected.value?.receipts.filter(item => item.unitType !== 'PARCEL' && (item.cartonMark || item.labels?.length)) || []) +const printableBoxCount = computed(() => printableReceipts.value.reduce((total, receipt) => total + (receipt.labels?.length || receipt.boxCount || 1), 0)) const photoUrls = reactive>({}) const rows = ref([]) const total = ref(0) @@ -175,18 +177,21 @@ function previewPhoto(fileId: string | number) { const url = photoUrls[String(fileId)] if (url) window.open(url, '_blank', 'noopener') } -async function printReturnLabel(receipt: ReturnReceipt) { - if (receipt.labelRepairRequired) { ElMessage.warning('签收箱数与当前箱记录不一致,请联系仓库核实并修复后打印'); return } - if (!receipt.cartonMark) return +async function printReturnLabel(receipt: ReturnReceipt | ReturnReceipt[]) { + const receipts = Array.isArray(receipt) ? receipt : [receipt] + if (receipts.some(item => item.labelRepairRequired)) { ElMessage.warning('签收箱数与当前箱记录不一致,请联系仓库核实并修复后打印'); return } + if (!receipts.length) return const popup = window.open('', '_blank') if (!popup) { ElMessage.warning('请允许浏览器打开打印窗口'); return } popup.opener = null try { - const labels = receipt.labels?.length - ? receipt.labels - : [{ cartonMark: receipt.cartonMark, printableHtml: receipt.printableHtml || (await getReturnInventoryLabel(receipt.cartonMark)).printableHtml }] + const groups = await Promise.all(receipts.map(async item => item.labels?.length + ? await Promise.all(item.labels.map(async label => ({ ...label, printableHtml: label.printableHtml || (await getReturnInventoryLabel(label.cartonMark)).printableHtml }))) + : item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml || (await getReturnInventoryLabel(item.cartonMark)).printableHtml }] : [])) + const labels = [...new Map(groups.flat().map(label => [label.cartonMark, label])).values()] + if (!labels.length) { popup.close(); ElMessage.warning('暂无可打印的箱唛'); return } const printStyles = [...new Set(labels.flatMap(label => label.printableHtml.match(/]*>[\s\S]*?<\/style>/gi) || []))].join('') - const printableHtml = receipt.printableHtml || `退货箱唛${printStyles}${labels.map((label) => { + const printableHtml = (receipts.length === 1 ? receipts[0]?.printableHtml : undefined) || `退货箱唛${printStyles}${labels.map((label) => { const html = label.printableHtml || '' const bodyStart = html.indexOf('', bodyStart) + 1 @@ -221,7 +226,12 @@ async function submit() { const payload = { overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: createIdempotencyKey(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) } if (editingReturnNo.value) await updateReturnOrder(editingReturnNo.value, payload) else await createReturnOrder(payload) - ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel' }) + const wasEditing = Boolean(editingReturnNo.value) + ElMessage.success(wasEditing ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm() + if (props.createPage || props.detailPage) await router.push({ name: 'overseas-return-relabel' }) + else await load() + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '保存退货预报失败') } finally { creating.value = false } } function statusType(status: string, needRelabel = false) { @@ -368,10 +378,11 @@ onBeforeUnmount(clearPhotoUrls)
-

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ receipt.labels?.length || receipt.boxCount || 1 }} 箱)

+

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ printableBoxCount }} 箱)

- + +
长 {{ pallet.length ?? '—' }} cm · 宽 {{ pallet.width ?? '—' }} cm · 高 {{ pallet.height ?? '—' }} cm · 重量 {{ pallet.weight ?? '—' }} kg
@@ -462,6 +473,7 @@ onBeforeUnmount(clearPhotoUrls) .return-detail-section :deep(.el-table .el-tag) { font-size:12px; } .return-pallet-list { padding:0 14px 10px; } .return-pallet-summary { margin-left:12px; color:#7c899c; font-size:12px; font-weight:400; } +.return-pallet-measurements { margin-bottom:8px; color:#7c899c; font-size:12px; } .return-photo-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:12px; padding:0 16px 16px; } .return-photo { min-width:0; overflow:hidden; padding:0; border:1px solid #dfe6ef; border-radius:6px; color:#526176; background:#fff; cursor:pointer; } .return-photo img,.return-photo>span { display:flex; width:100%; height:120px; align-items:center; justify-content:center; object-fit:cover; background:#f4f7fb; } diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index de8ff08..c7640d5 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -26,16 +26,14 @@ import { } from '@/modules/first-leg/api/firstLeg' import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination' import { createIdempotencyKey } from '@/utils/idempotencyKey' -import { createReturnRelabel, uploadReturnRelabelFile } from '../api/returnOrders' type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'edit' | 'detail' type LabelingMode = 'carton' | 'sku' | 'carton_and_sku' type RelabelTemplateType = 'carton' | 'sku' | 'mixed' -interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string } -interface ReturnRelabelSkuRow { skuCode: string; productNameZh: string; availableQuantity: number; selected: boolean; quantity: number } interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] } interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string } @@ -189,16 +187,21 @@ const selectedDiscardOrderId = ref('') const discardCandidatesLoading = ref(false) const relabelCandidates = ref([]) const selectedRelabelInboundOrderId = ref('') +const orderInfoCollapsed = ref(false) const relabelCandidatesLoading = ref(false) const transferInventoryCandidates = ref([]) const selectedTransferInventory = ref('') // Snowflake IDs exceed JavaScript's safe integer range. Keep them as strings // all the way to JSON so the backend receives the exact source order ID. const selectedTransferSourceOrderIds = ref([]) -const returnRelabelSkuRows = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') -const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && !isReturnRelabelSource.value && labelingMode.value === 'carton_and_sku') +const hideReturnSkuCartonMark = computed(() => isReturnRelabelSource.value && labelingMode.value === 'sku') +const selectedReturnCandidates = computed(() => relabelCandidates.value.filter(item => Boolean(item.returnCartonId) && item.inboundOrderNo === 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 returnSkuOnly = computed(() => isReturnRelabelSource.value && returnLooseCandidates.value.length > 0 && returnBoxCandidates.value.length === 0) +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 @@ -207,7 +210,7 @@ const relabelInboundOptions = computed(() => { const grouped = new Map() for (const candidate of relabelCandidates.value) { const returnOrderNo = candidate.inboundOrderNo || String(candidate.returnCartonId || '') - const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId) + const value = candidate.returnCartonId ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId) const current = grouped.get(value) if (current) { current.availablePackages += inventoryQuantity(candidate.availablePackages) @@ -215,11 +218,11 @@ const relabelInboundOptions = computed(() => { } else grouped.set(value, { value, - inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'), - inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '已签收退货库存' : (candidate.inboundCode || '—'), + inboundOrderNo: candidate.returnCartonId ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'), + inboundCode: candidate.returnCartonId ? '已签收退货库存' : (candidate.inboundCode || '—'), availablePackages: inventoryQuantity(candidate.availablePackages), relabelledPackages: inventoryQuantity(candidate.relabelledPackages), - quantityUnit: candidate.sourceType === 'RETURN_CARTON' ? '件' : '箱', + quantityUnit: candidate.returnCartonId ? '件' : '箱', }) } return [...grouped.values()] @@ -263,7 +266,10 @@ watch(() => cargoRows.value.map((row) => row.sku), (skus) => { if (sourceSku) row.sku = sourceSku }) }) -watch(labelingMode, (value) => { if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() }) +watch([returnSkuOnly, labelingMode], ([skuOnly, value]) => { + if (skuOnly && value !== 'sku') { labelingMode.value = 'sku'; return } + if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() +}) watch(() => form.destination, (value) => { if (props.service !== 'labeling') return for (const row of cargoRows.value) row.destinationWarehouseId = value @@ -293,6 +299,7 @@ onMounted(() => { onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() }) function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) } function resetForm() { + orderInfoCollapsed.value = false Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 }) cargoRows.value = [] transferRelabelRows.value = [] @@ -490,7 +497,7 @@ function clearTransferRelabelRows() { function addSkuRelabelRow() { const source = cargoRows.value[Math.min(skuRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))] skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: source?.id || null, - cartonMark: '', originalSku: source?.sku || '', newSku: '', quantity: 1, + cartonMark: hideReturnSkuCartonMark.value ? source?.cartonMark || '' : '', originalSku: source?.sku || '', newSku: '', quantity: 1, fbaCartonMark: '', destinationWarehouseId: form.destination, remark: '' }) } function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) } @@ -532,6 +539,16 @@ function chooseCreateTotalFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL') { function buildSkuRelabelItems(): RelabelCreate['items'] { const groups = new Map() for (const row of skuRelabelRows.value) { + if (hideReturnSkuCartonMark.value) { + const source = cargoRows.value.find(item => item.id === row.sourceRowId) + const candidate = selectedReturnCandidates.value.find(item => String(item.returnCartonId) === source?.returnCartonId) + if (candidate?.skuItems?.some(item => item.skuCode === row.originalSku.trim())) row.cartonMark = source?.cartonMark || '' + else { + const matches = selectedReturnCandidates.value.filter(item => item.skuItems?.some(sku => sku.skuCode === row.originalSku.trim())) + if (matches.length !== 1) throw new Error(`请从上方库存明细选择 SKU ${row.originalSku} 的来源库存`) + row.cartonMark = matches[0].cartonMark || '' + } + } const cartonMark = row.cartonMark.trim().toUpperCase() if (!cartonMark) throw new Error('请填写每个箱子的我方箱唛') const rows = groups.get(cartonMark) || [] @@ -546,7 +563,20 @@ function buildSkuRelabelItems(): RelabelCreate['items'] { throw new Error('填写的箱唛数量超过当前入仓单可用库存箱数') } return [...groups.entries()].map(([_, rows], groupIndex) => { - const source = cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex] + const source = isReturnRelabelSource.value + ? cargoRows.value.find(item => item.cartonMark === rows[0].cartonMark.trim()) + : cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex] + if (isReturnRelabelSource.value && !source) throw new Error('请选择该退货单实际可用的来源箱号') + if (isReturnRelabelSource.value) { + const candidate = selectedReturnCandidates.value.find(item => String(item.returnCartonId) === source?.returnCartonId) + if (candidate?.sourceType === 'RETURN_PARCEL' && labelingMode.value !== 'sku') throw new Error('散件库存只能贴 SKU,请先合箱后申请箱唛或混贴') + const requested = new Map() + for (const row of rows) { + const quantity = (requested.get(row.originalSku) || 0) + row.quantity + requested.set(row.originalSku, quantity) + if (quantity > Number(candidate?.skuItems?.find(item => item.skuCode === row.originalSku)?.quantity || 0)) throw new Error(`SKU ${row.originalSku} 超过来源箱可用数量`) + } + } if (!source?.returnCartonId && (!source?.sourceInventoryBalanceId || !source.sourceInventoryPositionId || !source.sourceInboundOrderId)) { throw new Error('来源库存已变化,请重新选择有库存的历史入仓单') } @@ -949,48 +979,18 @@ function listRowActions(candidate: object): TableAction[] { if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) }) return actions } -async function submitReturnRelabel() { - const items = returnRelabelSkuRows.value.filter(item => item.selected) - .map(item => ({ skuCode: item.skuCode, quantity: Number(item.quantity) })) - if (!selectedReturnOrderNo.value || !items.length) { ElMessage.warning('请至少选择一个需要贴标的 SKU'); return } - if (items.some(item => item.quantity < 1 || item.quantity > (returnRelabelSkuRows.value.find(row => row.skuCode === item.skuCode)?.availableQuantity || 0))) { - ElMessage.warning('本次贴标数量必须大于 0,且不能超过该 SKU 的库存总数'); return - } - const destination = fbaWarehouses.value.find(warehouse => warehouse.warehouseCode === form.destination) - if (!destination) { ElMessage.warning('请选择 FBA 目的仓'); return } - const selectedFile = cartonTotalFile.value || skuTotalFile.value - const fileScope = cartonTotalFile.value ? 'CARTON_TOTAL' as const : 'SKU_TOTAL' as const - if (!selectedFile) { ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return } - try { await ElMessageBox.confirm(`确认提交退货单 ${selectedReturnOrderNo.value} 的贴标申请吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return } - saving.value = true - try { - const created = await createReturnRelabel(selectedReturnOrderNo.value, { - items, - fileScope, - destinationFbaWarehouseId: String(destination.id), - destinationFbaWarehouseCode: destination.warehouseCode, - cartonRemark: form.remark.trim() || undefined, - idempotencyKey: createIdempotencyKey(), - }) - await uploadReturnRelabelFile(created.relabelNo, fileScope, selectedFile) - ElMessage.success(`贴标申请 ${created.relabelNo} 已提交,等待审核`) - await router.push({ name: 'overseas-labeling' }) - } catch (error) { ElMessage.error(error instanceof Error ? error.message : '退货贴标申请提交失败,请重试') } - finally { saving.value = false } -} watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => { if (nextSize !== previousSize && nextPage !== 1) { page.value = 1; return } void load() }) async function submit(action: 'draft' | 'submit' = 'submit') { - if (isReturnRelabelSource.value) { await submitReturnRelabel(); return } const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value const invalidCargo = props.service === 'disposal' ? !selectedDiscardOrderId.value || cargoRows.value.every((item) => item.quantity < 1) || cargoRows.value.some((item) => item.quantity < 0 || item.quantity > Number(item.availableQuantity || 0)) : (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton' - ? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1 + ? skuRelabelRows.value.some((item) => (!hideReturnSkuCartonMark.value && !item.cartonMark) || !item.originalSku || !item.newSku || item.quantity < 1 || (labelingMode.value === 'carton_and_sku' && !item.fbaCartonMark)) : cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || (props.service !== 'labeling' && item.quantity < 1)))) @@ -1019,7 +1019,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.shipmentNo || !item.cartonMark || !item.fbaCartonMark)) { ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return } - if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.sourceInventoryPositionId)) { + if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.returnCartonId && !item.sourceInventoryPositionId)) { ElMessage.warning('请选择有库存的历史入仓单'); return } if (props.service === 'disposal' && !form.reason) { @@ -1399,34 +1399,17 @@ function selectRelabelInventory(inboundOrderId: string) { const rows: CargoRow[] = [] const selectedCandidates = relabelCandidates.value.filter((item) => { const returnOrderNo = item.inboundOrderNo || String(item.returnCartonId || '') - return (item.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(item.inboundOrderId)) === inboundOrderId + return (item.returnCartonId ? `RETURN_ORDER:${returnOrderNo}` : String(item.inboundOrderId)) === inboundOrderId }) - returnRelabelSkuRows.value = [] if (inboundOrderId.startsWith('RETURN_ORDER:')) { - const grouped = new Map() - for (const candidate of selectedCandidates) { - const skuItems = candidate.skuItems?.length - ? candidate.skuItems - : candidate.skuCode ? [{ skuCode: candidate.skuCode, quantity: inventoryQuantity(candidate.availablePackages) }] : [] - for (const item of skuItems) { - const skuCode = item.skuCode.trim() - if (!skuCode) continue - const key = skuCode.toUpperCase() - const current = grouped.get(key) - const quantity = inventoryQuantity(item.quantity) - if (current) current.availableQuantity += quantity - else grouped.set(key, { - skuCode, - productNameZh: candidate.skuCode?.trim().toUpperCase() === key ? (candidate.productNameZh || '') : '', - availableQuantity: quantity, - selected: false, - // 可贴库存和本次申请数量是两个概念;重复换标时默认不应自动带入上次/首个箱子的数量。 - quantity: 0, - }) - } - } - returnRelabelSkuRows.value = [...grouped.values()] labelingMode.value = 'sku' + for (const candidate of selectedCandidates) rows.push({ + id: ++cargoSequence, sku: candidate.skuItems?.[0]?.skuCode || candidate.skuCode || '', + name: candidate.productNameZh || '退货库存', included: false, quantity: 1, availableQuantity: 1, + returnCartonId: String(candidate.returnCartonId), sourceInboundOrderNo: candidate.inboundOrderNo, + fnsku: '', shipmentNo: candidate.inboundOrderNo || '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', + cartonMark: candidate.cartonMark || '', fbaCartonMark: '', destinationWarehouseId: '', remark: '', + }) } else for (const candidate of selectedCandidates) { for (const position of candidate.positions) rows.push(...Array.from({ length: Math.min(inventoryQuantity(position.availablePackages), 500) }, () => ({ id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物', @@ -1440,7 +1423,7 @@ function selectRelabelInventory(inboundOrderId: string) { }))) } cargoRows.value = rows.slice(0, 500) - if (cargoRows.value.length) cargoRows.value[0].included = true + if (cargoRows.value.length && !inboundOrderId.startsWith('RETURN_ORDER:')) cargoRows.value[0].included = true skuRelabelRows.value = [] importedRelabelBindings.value = [] shippingLabelFile.value = null @@ -1448,6 +1431,22 @@ function selectRelabelInventory(inboundOrderId: string) { skuTotalFile.value = null if (!isReturnRelabelSource.value && labelingMode.value !== 'carton') addSkuRelabelRow() } +function addReturnSkuBinding(source: RelabelInventoryCandidate, skuCode: string) { + const sku = source.skuItems?.find(item => item.skuCode === skuCode) + if (!sku) return + const row = skuRelabelRows.value.find(item => item.cartonMark === source.cartonMark && item.originalSku === skuCode) + if (row) return + const sourceRow = cargoRows.value.find(item => item.returnCartonId === String(source.returnCartonId)) + skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: sourceRow?.id || null, cartonMark: source.cartonMark || '', originalSku: skuCode, newSku: '', quantity: Math.max(1, Number(sku.quantity)), fbaCartonMark: '', destinationWarehouseId: '', remark: '' }) +} +function addReturnBoxBinding(source: RelabelInventoryCandidate) { + if (labelingMode.value !== 'carton') { + for (const sku of source.skuItems || []) addReturnSkuBinding(source, sku.skuCode) + return + } + if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return + cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '退货箱', shipmentNo: selectedReturnOrderNo.value, cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) +}