From 308f7573c1114d05efa1f281f2ecc9ea12e36076 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Sun, 20 Sep 2026 16:55:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components.d.ts | 2 + .../overseas-warehouse/api/returnOrders.ts | 2 +- .../views/ReturnOrdersView.test.mjs | 27 +- .../views/ReturnOrdersView.vue | 146 ++++++++-- .../views/WarehouseServiceView.vue | 261 +++++++++++++----- .../views/relabelAttachments.test.mjs | 2 +- .../views/warehouseTransferForm.test.mjs | 8 +- src/navigation/clientNavigation.test.mjs | 2 +- src/navigation/clientNavigation.ts | 2 +- 9 files changed, 354 insertions(+), 98 deletions(-) diff --git a/src/components.d.ts b/src/components.d.ts index c3c224a..bcd7883 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -22,6 +22,8 @@ declare module 'vue' { ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts index 38a8205..c6fd930 100644 --- a/src/modules/overseas-warehouse/api/returnOrders.ts +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -45,5 +45,5 @@ export function uploadReturnRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' return overseasApiRequest({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope }, data, headers: { 'Content-Type': 'multipart/form-data' } }) } export function downloadReturnPhoto(returnNo: string, fileId: string | number) { - return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/photos/${encodeURIComponent(String(fileId))}/download` }) + return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/photos/files/${encodeURIComponent(String(fileId))}/download` }) } diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index d13b0e0..39bc20e 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -3,6 +3,15 @@ import { readFileSync } from 'node:fs' 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') + +test('退货签收照片通过客户鉴权下载接口加载并释放对象 URL', () => { + assert.match(api, /\/return-orders\/\$\{encodeURIComponent\(returnNo\)\}\/photos\/files\/\$\{encodeURIComponent\(String\(fileId\)\)\}\/download/) + assert.match(api, /overseasBlobRequest/) + assert.match(view, /URL\.createObjectURL\(await downloadReturnPhoto\(detailReturnNo\.value, photo\.fileId\)\)/) + assert.match(view, /URL\.revokeObjectURL\(url\)/) + assert.match(view, /onBeforeUnmount\(clearPhotoUrls\)/) +}) test('新建退货预报选择海外仓后带出联系人和电话', () => { assert.match(view, /@change="selectOperationWarehouse"/) @@ -18,7 +27,7 @@ test('新建退货预报选择海外仓后带出联系人和电话', () => { }) test('新建退货预报使用加宽的双列表单弹窗', () => { - assert.match(view, /title="新建退货预报" width="1120px"/) + assert.match(view, /:title="editingReturnNo \? '编辑退货预报' : '新建退货预报'" width="1120px"/) assert.match(view, //) assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/) }) @@ -28,10 +37,10 @@ test('退货列表和详情使用客户可读仓库、状态与时间', () => { assert.match(view, /return warehouse \? warehouseLabel\(warehouse\) : '未关联'/) assert.match(view, /PENDING_RECEIPT: '待签收'/) assert.match(view, /PARTIALLY_RECEIVED: '部分签收'/) - assert.match(view, /COMPLETED: '已完成'/) + assert.match(view, /return needRelabel \? '已完成入库\/待换标' : '已完成入库'/) assert.match(view, /function formatDateTime\(value\?: string \| null\)/) assert.match(view, /\{\{ operationWarehouseLabel\(row\.overseasWarehouseId\) \}\}/) - assert.match(view, /\{\{ statusLabel\(row\.status\) \}\}/) + assert.match(view, /\{\{ statusLabel\(row\.status, row\.needRelabel\) \}\}/) assert.match(view, /\{\{ formatDateTime\(row\.createdAt\) \}\}/) assert.doesNotMatch(view, / { test('可操作退货表格把操作列固定在最左侧', () => { const createTable = view.slice(view.indexOf('', view.indexOf(']*:row-actions="rowActions"/) assert.match(createTable, /^/) }) @@ -58,3 +67,13 @@ test('退货签收详情使用独立路由和紧凑连续页面', () => { assert.doesNotMatch(view, /showDetail/) assert.doesNotMatch(view, /class="return-detail-dialog"/) }) + +test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => { + assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/) + assert.match(view, /await updateReturnOrder\(editingReturnNo\.value, payload\)/) + assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true && !order\.relabelNo/) + assert.match(view, /name: 'overseas-labeling-create'/) + assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\) \}/) + assert.match(view, /prop: 'relabelNo', label: '换标单号'/) + assert.match(view, /label: '换标单状态'/) +}) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 1e4aa52..1c3b9b4 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -1,5 +1,5 @@ @@ -201,7 +269,7 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP
{{ selected.order.returnNo }}{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }} · 退货签收
- {{ statusLabel(selected.order.status) }} +
贴标申请{{ statusLabel(selected.order.status, selected.order.needRelabel) }}

签收概览

@@ -209,6 +277,8 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP
实收 / 预报{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}
SKU 种类{{ selected.order.skuTypes }}
是否换标{{ selected.order.needRelabel ? '需要换标' : '否' }}
+
换标单号{{ selected.order.relabelNo || '—' }}
+
换标单状态{{ relabelStatusLabel(selected.order.relabelStatus) }}
客户参考号{{ selected.order.customerReferenceNo || '—' }}
海外仓联系人{{ selected.order.warehouseContactName || '—' }}
联系电话{{ selected.order.warehouseContactPhone || '—' }}
@@ -220,6 +290,31 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP

预报与实收

+
+

打托明细({{ selected.pallets?.length || 0 }} 托)

+ + + + + + + + + + + +
+
+

退货打托照片({{ selected.photos?.length || 0 }} 张)

+
+ +
+ +

签收批次

@@ -237,6 +332,13 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP .return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; } .return-table-card { overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; box-shadow:none; } .return-table-card :deep(.el-table) { width:100%; } +.return-status--pending-review { --el-tag-bg-color:#fff7e6; --el-tag-border-color:#f4c26b; --el-tag-text-color:#a86100; } +.return-status--pending-receipt { --el-tag-bg-color:#edf5ff; --el-tag-border-color:#8bbcff; --el-tag-text-color:#1764c0; } +.return-status--partially-received { --el-tag-bg-color:#f5efff; --el-tag-border-color:#b99aee; --el-tag-text-color:#6f42b5; } +.return-status--relabel { --el-tag-bg-color:#fff1e8; --el-tag-border-color:#f1a36f; --el-tag-text-color:#bd4d08; } +.return-status--completed { --el-tag-bg-color:#edf9f0; --el-tag-border-color:#8bd29b; --el-tag-text-color:#23823b; } +.return-status--rejected { --el-tag-bg-color:#fff0f0; --el-tag-border-color:#ee9696; --el-tag-text-color:#c73535; } +.return-status--cancelled { --el-tag-bg-color:#f2f4f7; --el-tag-border-color:#c7cdd6; --el-tag-text-color:#667085; } .return-pagination { display:flex; justify-content:flex-end; padding:14px 18px; border-top:1px solid #eef1f5; } .return-pagination :deep(.el-pagination) { margin:0; } .return-pagination-total { color:#526176; font-size:14px; } @@ -248,6 +350,7 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP .return-detail-title { display:flex; min-width:0; flex-direction:column; gap:3px; } .return-detail-title strong { overflow:hidden; color:#1f2d45; font-size:18px; text-overflow:ellipsis; white-space:nowrap; } .return-detail-title span { color:#7c899c; font-size:12px; } +.return-detail-actions { display:flex; align-items:center; gap:8px; } .return-detail-section + .return-detail-section { border-top:1px solid var(--client-border); } .return-detail-section > h3 { min-height:40px; margin:0; padding:11px 16px 9px; color:#26364f; font-size:14px; } .return-detail-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border-top:1px solid #edf1f5; } @@ -256,7 +359,14 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP .return-detail-grid strong,.return-detail-remark strong { min-width:0; overflow-wrap:anywhere; color:#2c3c55; font-size:13px; } .return-detail-remark { display:flex; align-items:flex-start; gap:10px; padding:10px 14px; background:#f8fafc; } .return-detail-section :deep(.data-table) { border:0; border-radius:0; } +.return-pallet-list { padding:0 16px 12px; } +.return-pallet-summary { margin-left:12px; color:#7c899c; font-size:12px; font-weight:400; } +.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; } +.return-photo small { display:block; overflow:hidden; padding:7px; text-overflow:ellipsis; white-space:nowrap; } @media (max-width: 760px) { .return-create-form { grid-template-columns:1fr; } } @media (max-width: 1100px) { .return-detail-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } } -@media (max-width: 720px) { .return-detail-header { grid-template-columns:1fr auto; } .return-back { grid-column:1/-1; } .return-pagination { padding:12px; } } +@media (max-width: 900px) { .return-photo-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } } +@media (max-width: 720px) { .return-detail-header { grid-template-columns:1fr auto; } .return-back { grid-column:1/-1; } .return-pagination { padding:12px; } .return-photo-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } } diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 9f77cf2..10901b2 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -25,6 +25,7 @@ import { type MerchantWarehouseDetail, } from '@/modules/first-leg/api/firstLeg' import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination' +import { createReturnRelabel, uploadReturnRelabelFile } from '../api/returnOrders' type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'edit' | 'detail' @@ -33,6 +34,7 @@ type RelabelTemplateType = 'carton' | 'sku' | 'mixed' interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; 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 } @@ -100,11 +102,6 @@ const shippingLabelFile = ref(null) const skuRelabelRows = ref([]) const importedRelabelBindings = ref([]) const cartonDetailRows = computed(() => cargoRows.value.filter((row) => row.included)) -const labelingModeHint = computed(() => labelingMode.value === 'carton' - ? '只替换外箱箱唛,不修改箱内商品 SKU 标签;整张申请统一使用订单信息中的 FBA 目的仓。' - : labelingMode.value === 'sku' - ? '只替换箱内商品 SKU 标签,保留原箱唛;同一箱唛可以添加多条 SKU 明细。' - : '同一箱同时替换商品 SKU 标签和外箱箱唛;整张申请统一使用订单信息中的 FBA 目的仓。') const submittedRows = ref[]>([]) const page = ref(1) const pageSize = ref(15) @@ -168,18 +165,27 @@ const discardCandidatesLoading = ref(false) const relabelCandidates = ref([]) const selectedRelabelInboundOrderId = ref('') const relabelCandidatesLoading = ref(false) +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' && !isReturnRelabelSource.value && 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 relabelInboundOptions = computed(() => { - const grouped = new Map() + const grouped = new Map() for (const candidate of relabelCandidates.value) { - const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_CARTON:${candidate.returnCartonId}` : String(candidate.inboundOrderId) + const returnOrderNo = candidate.inboundOrderNo || String(candidate.returnCartonId || '') + const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId) const current = grouped.get(value) - const availablePackages = Number(candidate.availablePackages) - if (current) current.availablePackages += Number.isFinite(availablePackages) ? availablePackages : 0 + if (current) current.availablePackages += inventoryQuantity(candidate.availablePackages) else grouped.set(value, { value, - inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货合箱 ${candidate.cartonMark || '—'}` : (candidate.inboundOrderNo || '—'), - inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '仓库唛头库存' : (candidate.inboundCode || '—'), - availablePackages: Number.isFinite(availablePackages) ? availablePackages : 0, + inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'), + inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '已签收退货库存' : (candidate.inboundCode || '—'), + availablePackages: inventoryQuantity(candidate.availablePackages), + quantityUnit: candidate.sourceType === 'RETURN_CARTON' ? '件' : '箱', }) } return [...grouped.values()] @@ -263,8 +269,21 @@ function resetForm() { } async function openCreate() { resetForm(); mode.value = 'create' - if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer') void loadWarehouses() - if (props.service === 'labeling') return + if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer') await loadWarehouses() + if (props.service === 'labeling') { + const requestedReturnNo = String(route.query.returnNo || '').trim() + const requestedWarehouseId = String(route.query.warehouseId || '').trim() + if (requestedReturnNo && requestedWarehouseId && operationWarehouses.value.some(warehouse => String(warehouse.id) === requestedWarehouseId)) { + form.warehouse = requestedWarehouseId + await selectOperationWarehouse() + const sourceValue = `RETURN_ORDER:${requestedReturnNo}` + if (relabelInboundOptions.value.some(option => option.value === sourceValue)) { + selectedRelabelInboundOrderId.value = sourceValue + selectRelabelInventory(sourceValue) + } else ElMessage.warning(`退货单 ${requestedReturnNo} 暂无可申请贴标的库存`) + } + return + } if (props.service !== 'disposal') { addCargoRow(); return } } @@ -337,6 +356,36 @@ async function selectDiscardWarehouse() { } function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } } function addCargoRow() { cargoRows.value.push(createCargoRow()) } +function escapePrintHtml(value: string) { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') +} +function printTransferDraftCartonMarks() { + if (props.service !== 'transfer') return + const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.quantity) > 0) + if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.quantity)))) { + ElMessage.warning('请先填写商品 SKU 和对应的整数箱数'); return + } + const totalCartons = validRows.reduce((sum, row) => sum + Number(row.quantity), 0) + if (totalCartons > 500) { ElMessage.warning('单次最多打印 500 张箱唛'); return } + const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14) + const labels: Array<{ mark: string; sku: string; index: number }> = [] + let cartonIndex = 0 + for (const [rowIndex, row] of validRows.entries()) { + let marks = transferCustomerMarks(row) + if (marks.length !== Number(row.quantity)) { + marks = Array.from({ length: Number(row.quantity) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`) + row.customerCartonMarks = marks.join('\n') + } + for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex }) + } + const printWindow = window.open('', '_blank') + if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return } + printWindow.opener = null + const sections = labels.map((label) => `
仓储转运客户箱唛
${escapePrintHtml(label.mark)}
SKU:${escapePrintHtml(label.sku)}
${label.index} / ${labels.length}
`).join('') + printWindow.document.open() + printWindow.document.write(`仓储转运客户箱唛${sections}`) + printWindow.document.close(); printWindow.focus(); printWindow.print() +} function addTransferRelabelRow() { transferRelabelRows.value.push(createCargoRow()) } function clearTransferRelabelRows() { transferRelabelRows.value = [] @@ -365,8 +414,14 @@ function applyExistingCartonBinding(row: SkuRelabelRow) { if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId } function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null } -function selectCartonTotalFile(event: Event) { cartonTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null } -function selectSkuTotalFile(event: Event) { skuTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null } +function selectCartonTotalFile(event: Event) { + cartonTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null + if (cartonTotalFile.value && !requiresBothTotalFiles.value) clearCreateTotalFile('SKU_TOTAL') +} +function selectSkuTotalFile(event: Event) { + skuTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null + if (skuTotalFile.value && !requiresBothTotalFiles.value) clearCreateTotalFile('CARTON_TOTAL') +} function clearCreateTotalFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL') { if (saving.value) return const file = scope === 'CARTON_TOTAL' ? cartonTotalFile : skuTotalFile @@ -663,7 +718,8 @@ function warehouseDisplay(value?: string) { function statusLabel(value: string) { if (props.service === 'labeling') { if (value === 'PROCESSING') return '处理中' - return ['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理' + if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认' + return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理' } if (props.service === 'transfer') { const transferLabels: Record = { @@ -737,11 +793,41 @@ 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: crypto.randomUUID(), + }) + 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' @@ -758,6 +844,12 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (props.service === 'labeling' && (!form.labelDeadline || !form.contact || !form.phone)) { ElMessage.warning('请填写贴标截止时间和海外仓联系人信息'); return } + if (props.service === 'labeling' && requiresBothTotalFiles.value && (!cartonTotalFile.value || !skuTotalFile.value)) { + ElMessage.warning('SKU 和箱唛都换时,必须同时上传箱唛总文件和 SKU 总文件'); return + } + if (props.service === 'labeling' && !requiresBothTotalFiles.value && !cartonTotalFile.value && !skuTotalFile.value) { + ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return + } if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.deliveryMethod) { ElMessage.warning('请选择换标完成后的派送方式'); return } @@ -1045,15 +1137,31 @@ async function selectOperationWarehouse() { } function selectRelabelInventory(inboundOrderId: string) { const rows: CargoRow[] = [] - for (const candidate of relabelCandidates.value.filter((item) => (item.sourceType === 'RETURN_CARTON' ? `RETURN_CARTON:${item.returnCartonId}` : String(item.inboundOrderId)) === inboundOrderId)) { - if (candidate.sourceType === 'RETURN_CARTON') rows.push({ - id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '退货合箱库存', - included: true, quantity: 1, availableQuantity: 1, returnCartonId: String(candidate.returnCartonId), - sourceInboundOrderNo: candidate.inboundOrderNo, sourceWarehouseName: candidate.overseasWarehouseName, - fnsku: '', shipmentNo: candidate.inboundOrderNo || candidate.cartonMark || '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', - cartonMark: candidate.cartonMark || '', fbaCartonMark: '', destinationWarehouseId: candidate.destinationFbaWarehouseCode || '', remark: '', - }) - else for (const position of candidate.positions) rows.push(...Array.from({ length: Math.min(position.availablePackages, 500) }, () => ({ + 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 + }) + 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 }) + } + } + returnRelabelSkuRows.value = [...grouped.values()] + labelingMode.value = 'sku' + } 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 || '库存货物', included: false, quantity: 1, availableQuantity: 1, sourceInventoryBalanceId: String(candidate.inventoryBalanceId), sourceInventoryPositionId: String(position.positionId), @@ -1071,12 +1179,12 @@ function selectRelabelInventory(inboundOrderId: string) { shippingLabelFile.value = null cartonTotalFile.value = null skuTotalFile.value = null - if (labelingMode.value !== 'carton') addSkuRelabelRow() + if (!isReturnRelabelSource.value && labelingMode.value !== 'carton') addSkuRelabelRow() }