Merge test into dev

This commit is contained in:
2026-09-14 12:06:46 +08:00
43 changed files with 788 additions and 102 deletions
@@ -13,6 +13,15 @@ export interface ReturnInventorySummaryItem {
export interface ReturnInventoryCartonItem { skuCode: string; productNameZh?: string; quantity: number }
export interface ReturnInventoryCartonSummary {
cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string
skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string
}
export function listReturnInventoryCartons(params: Record<string, unknown>) {
return overseasApiRequest<PageResult<ReturnInventoryCartonSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/cartons', params })
}
export interface ReturnInventoryCartonDetail {
cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string
skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; receivedAt: string; items: ReturnInventoryCartonItem[]
+1 -1
View File
@@ -38,7 +38,7 @@ export const overseasWarehouseModule = {
{
path: 'overseas-warehouse/return-cartons', name: 'overseas-return-cartons',
component: () => import('./views/ReturnInventoryView.vue'), props: { cartonOnly: true }, release: 'active',
meta: { title: '退货箱号管理', description: '查询合箱后的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
meta: { title: '退货箱号管理', description: '查询签收及合箱生成的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
navigation: { key: 'overseas-return-cartons', icon: Boxes },
},
],
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const inventory = readFileSync(new URL('./ReturnInventoryView.vue', import.meta.url), 'utf8')
const orders = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
test('desktop filters reserve six slots and inventory time and statuses have readable presentation', () => {
assert.match(inventory, /grid-template-columns: repeat\(4, minmax\(150px, 1fr\)\) 130px auto/)
assert.match(inventory, /white-space: nowrap/)
assert.match(inventory, /cartonStatusLabel\(cartonDetail.status\)/)
assert.match(inventory, /formatInventoryTime\(row.createdAt\)/)
assert.match(inventory, /formatInventoryTime\(row.updatedAt\)/)
})
test('inventory failures are visible and are not presented as empty inventory', () => {
assert.match(inventory, /catch \(error\)/)
assert.match(inventory, /<el-alert v-if="loadError"/)
assert.equal((inventory.match(/:empty-text="loadError \?/g) || []).length, 3)
assert.match(inventory, /当前账号无权访问退货库存/)
assert.match(inventory, /error\.requestId/)
assert.match(inventory, /error\.traceId/)
assert.match(inventory, /summaryRows\.value = \[\]/)
assert.match(inventory, /cartonRows\.value = \[\]/)
})
test('return pagination receives numeric totals from the wire format', () => {
assert.equal((inventory.match(/total\.value = Number\(page.total\)/g) || []).length, 3)
assert.match(orders, /total\.value = Number\(response.total\)/)
})
test('inventory actions lead both tables and timestamps describe inventory changes', () => {
const tables = inventory.match(/<el-table\b[^>]*>[\s\S]*?<\/el-table>/g).slice(0, 3)
for (const table of tables) {
assert.match(table, /<el-table\b[^>]*>\s*<el-table-column label="操作"[^>]*fixed="left"/)
}
assert.match(inventory, /prop="updatedAt" label="库存更新时间"/)
assert.doesNotMatch(inventory, /实收并上架|最近入库时间/)
})
test('carton management uses server carton pagination rather than deduplicating SKU pages', () => {
assert.match(inventory, /if \(props.cartonOnly\) \{\s*const page = await listReturnInventoryCartons/)
assert.match(inventory, /:data="cartonSummaryRows" row-key="cartonMark"/)
assert.match(inventory, /cartonSummaryRows.value = \[\]/)
})
test('printing opens its window before awaiting the label and closes on failure', () => {
const print = inventory.slice(inventory.indexOf('async function printLabel'), inventory.indexOf('onMounted('))
assert.ok(print.indexOf('window.open(') < print.indexOf('await getReturnInventoryLabel('))
assert.match(print, /if \(printWindow.closed\) return/)
assert.match(print, /catch \(error\) \{\s*printWindow.close\(\)/)
})
test('warehouse selectors and displays use the catalog instead of internal IDs', () => {
assert.match(inventory, /from '@\/modules\/first-leg\/public'/)
assert.match(inventory, /warehouseType: 'OVERSEAS', page, size: 200/)
assert.match(inventory, /warehouseLabel\(cartonDetail.overseasWarehouseId\)/)
assert.match(inventory, /warehouse\.warehouseName/)
assert.match(inventory, /warehouse\.warehouseCode/)
assert.doesNotMatch(inventory, /海外仓 ID|\{\{ cartonDetail.overseasWarehouseId \}\}|prop="overseasWarehouseId"/)
})
@@ -2,11 +2,15 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Printer, Search } from '@lucide/vue'
import { ApiError } from '@/api/types'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import {
getReturnInventoryCarton,
getReturnInventoryLabel,
listReturnInventory,
listReturnInventorySummary,
listReturnInventoryCartons,
type ReturnInventoryCartonSummary,
type ReturnInventoryCartonDetail,
type ReturnInventoryItem,
type ReturnInventorySummaryItem,
@@ -16,8 +20,35 @@ const activeTab = ref<'summary' | 'cartons'>('summary')
const props = defineProps<{ cartonOnly?: boolean }>()
if (props.cartonOnly) activeTab.value = 'cartons'
const loading = ref(false)
const loadError = ref('')
const warehouses = ref<MerchantWarehouse[]>([])
const warehouseError = ref('')
const warehousesLoading = ref(false)
function formatInventoryTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
function cartonStatusLabel(value: string) { return ({ AVAILABLE: '可用', MERGED: '已合箱', OUTBOUNDED: '已出库' } as Record<string, string>)[value] || '—' }
function warehouseLabel(warehouseId: string) {
const warehouse = warehouses.value.find(item => String(item.id) === String(warehouseId))
return warehouse ? `${warehouse.warehouseName} · ${warehouse.warehouseCode}` : '—'
}
async function loadWarehouses() {
warehousesLoading.value = true
warehouseError.value = ''
try {
const result: MerchantWarehouse[] = []
for (let page = 1; ; page++) {
const response = await getMerchantWarehouses({ warehouseType: 'OVERSEAS', page, size: 200 })
result.push(...response.records)
if (!response.records.length || result.length >= Number(response.total)) break
}
warehouses.value = result
} catch (error) {
warehouses.value = []
warehouseError.value = error instanceof Error ? error.message : '海外仓目录加载失败,请重试'
} finally { warehousesLoading.value = false }
}
const summaryRows = ref<ReturnInventorySummaryItem[]>([])
const cartonRows = ref<ReturnInventoryItem[]>([])
const cartonSummaryRows = ref<ReturnInventoryCartonSummary[]>([])
const total = ref(0)
const showCartonDetail = ref(false)
const cartonDetail = ref<ReturnInventoryCartonDetail>()
@@ -37,16 +68,35 @@ function params(includeCartonMark: boolean) {
async function load() {
loading.value = true
loadError.value = ''
try {
if (props.cartonOnly) {
const page = await listReturnInventoryCartons(params(true))
cartonSummaryRows.value = page.records
total.value = Number(page.total)
return
}
if (activeTab.value === 'summary') {
const page = await listReturnInventorySummary(params(false))
summaryRows.value = page.records
total.value = page.total
total.value = Number(page.total)
return
}
const page = await listReturnInventory(params(true))
cartonRows.value = page.records
total.value = page.total
total.value = Number(page.total)
} catch (error) {
summaryRows.value = []
cartonRows.value = []
cartonSummaryRows.value = []
total.value = 0
const message = error instanceof ApiError && (error.code === 'A0403' || error.status === 403)
? '当前账号无权访问退货库存,请联系管理员核对海外库存权限。'
: error instanceof Error ? error.message : '退货库存加载失败,请重试'
const diagnostics = error instanceof ApiError
? [error.code, error.requestId && `requestId: ${error.requestId}`, error.traceId && `traceId: ${error.traceId}`].filter(Boolean).join(' · ')
: ''
loadError.value = diagnostics ? `${message}(${diagnostics})` : message
} finally { loading.value = false }
}
@@ -67,51 +117,71 @@ async function openCarton(cartonMark: string) {
}
async function printLabel(cartonMark: string) {
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null
printWindow.document.title = '正在加载退货箱唛'
printWindow.document.body.textContent = '正在加载退货箱唛,请稍候…'
try {
const label = await getReturnInventoryLabel(cartonMark)
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null
if (printWindow.closed) return
printWindow.document.open()
printWindow.document.write(label.printableHtml)
printWindow.document.close()
printWindow.focus()
printWindow.print()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') }
} catch (error) {
printWindow.close()
ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败')
}
}
onMounted(load)
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template>
<section class="return-inventory-page">
<div class="page-heading"><div><h2>{{ props.cartonOnly ? '退货箱号管理' : '退货库存管理' }}</h2><p>{{ props.cartonOnly ? '查询合箱完成后的退货箱号、箱规和箱内 SKU 明细。' : '库存数量以仓库 PDA 实收并上架的数量为准。' }}</p></div></div>
<div class="page-heading"><div><h2>{{ props.cartonOnly ? '退货箱号管理' : '退货库存管理' }}</h2><p>{{ props.cartonOnly ? '查询签收及合箱生成的退货箱号、箱规和箱内 SKU 明细。' : '库存数量以仓库 PDA 确认实收后的入账数量为准。' }}</p></div></div>
<el-tabs v-if="!props.cartonOnly" v-model="activeTab" @tab-change="changeTab"><el-tab-pane label="SKU 汇总库存" name="summary" /><el-tab-pane label="箱号明细" name="cartons" /></el-tabs>
<div class="filter-bar">
<el-input v-model="query.overseasWarehouseId" clearable placeholder="海外仓 ID" @keyup.enter="search" />
<div class="filter-bar" :class="{ 'carton-filters': activeTab === 'cartons' }">
<el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="海外仓" :loading="warehousesLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select>
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" />
<el-input v-if="activeTab === 'cartons'" v-model="query.cartonMark" clearable placeholder="箱号 / 箱唛" @keyup.enter="search" />
<el-input v-if="activeTab === 'cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" />
<el-select v-if="activeTab === 'cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select>
<el-button type="primary" @click="search"><Search :size="16" />查询</el-button>
</div>
<el-table v-if="activeTab === 'summary'" v-loading="loading" :data="summaryRows">
<el-table-column prop="overseasWarehouseId" label="海外仓" min-width="150" /><el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" />
<el-alert v-if="warehouseError" :title="warehouseError" type="warning" show-icon :closable="false"><el-button link type="primary" @click="loadWarehouses">重试加载海外仓</el-button></el-alert>
<el-table v-if="activeTab === 'summary'" v-loading="loading" :data="summaryRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="110" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openSkuCartons(row.skuCode)">查看箱号</el-button></template></el-table-column>
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="productNameZh" label="中文品名" min-width="190" show-overflow-tooltip /><el-table-column prop="availableQuantity" label="可用库存" min-width="120" align="right" />
<el-table-column prop="cartonQuantity" label="所在箱数" min-width="120" align="right" /><el-table-column prop="updatedAt" label="最近入库时间" min-width="180" />
<el-table-column label="操作" width="110" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openSkuCartons(row.skuCode)">查看箱号</el-button></template></el-table-column>
<el-table-column prop="cartonQuantity" label="所在箱数" min-width="120" align="right" /><el-table-column prop="updatedAt" label="库存更新时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.updatedAt) }}</span></template></el-table-column>
</el-table>
<el-table v-else v-loading="loading" :data="cartonRows">
<el-table v-else-if="props.cartonOnly" v-loading="loading" :data="cartonSummaryRows" row-key="cartonMark" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
<el-table-column prop="cartonMark" label="箱号" min-width="235" />
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
<el-table-column prop="skuTypes" label="箱内 SKU 种类" min-width="130" align="right" />
<el-table-column prop="totalQuantity" label="箱内总件数" min-width="115" align="right" />
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="235" />
<el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" />
<el-table-column label="箱规" min-width="160"><template #default="{ row }">{{ row.length && row.width && row.height ? `${row.length} × ${row.width} × ${row.height} cm` : '—' }}</template></el-table-column>
<el-table-column label="重量" min-width="100"><template #default="{ row }">{{ row.weight ? `${row.weight} kg` : '—' }}</template></el-table-column>
<el-table-column prop="createdAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.createdAt) }}</span></template></el-table-column>
</el-table>
<el-table v-else v-loading="loading" :data="cartonRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
<el-table-column prop="cartonMark" label="箱号" min-width="235"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">{{ row.cartonMark }}</el-button></template></el-table-column>
<el-table-column prop="overseasWarehouseId" label="海外仓" min-width="130" /><el-table-column prop="skuCode" label="SKU" min-width="150" />
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" />
<el-table-column prop="productNameZh" label="中文品名" min-width="170" show-overflow-tooltip /><el-table-column prop="quantity" label="本箱 SKU 数量" min-width="135" align="right" />
<el-table-column prop="cartonSkuTypes" label="箱内 SKU 种类" min-width="130" align="right" /><el-table-column prop="cartonTotalQuantity" label="箱内总件数" min-width="115" align="right" />
<el-table-column prop="status" label="箱状态" min-width="100" /><el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" /><el-table-column prop="receivedAt" label="签收时间" min-width="175" />
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
<el-table-column prop="status" label="箱状态" min-width="100"><template #default="{ row }">{{ cartonStatusLabel(row.status) }}</template></el-table-column><el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" /><el-table-column prop="receivedAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.receivedAt) }}</span></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
<el-drawer v-model="showCartonDetail" title="退货箱明细" size="680px"><template v-if="cartonDetail">
<el-descriptions :column="2" border><el-descriptions-item label="箱号">{{ cartonDetail.cartonMark }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ cartonDetail.overseasWarehouseId }}</el-descriptions-item><el-descriptions-item label="来源退货单">{{ cartonDetail.sourceReturnNo || '—' }}</el-descriptions-item><el-descriptions-item label="签收批次">{{ cartonDetail.sourceBatchNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ cartonDetail.status }}</el-descriptions-item><el-descriptions-item label="SKU 种类">{{ cartonDetail.skuTypes }}</el-descriptions-item><el-descriptions-item label="箱内总件数">{{ cartonDetail.totalQuantity }}</el-descriptions-item><el-descriptions-item label="箱规">{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</el-descriptions-item><el-descriptions-item label="重量">{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</el-descriptions-item></el-descriptions>
<el-descriptions :column="2" border><el-descriptions-item label="箱号">{{ cartonDetail.cartonMark }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel(cartonDetail.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="来源退货单">{{ cartonDetail.sourceReturnNo || '—' }}</el-descriptions-item><el-descriptions-item label="签收批次">{{ cartonDetail.sourceBatchNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ cartonStatusLabel(cartonDetail.status) }}</el-descriptions-item><el-descriptions-item label="SKU 种类">{{ cartonDetail.skuTypes }}</el-descriptions-item><el-descriptions-item label="箱内总件数">{{ cartonDetail.totalQuantity }}</el-descriptions-item><el-descriptions-item label="箱规">{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</el-descriptions-item><el-descriptions-item label="重量">{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</el-descriptions-item></el-descriptions>
<div class="drawer-heading"><h3>箱内 SKU</h3><el-button type="primary" @click="printLabel(cartonDetail.cartonMark)"><Printer :size="16" />打印箱唛</el-button></div>
<el-table :data="cartonDetail.items" size="small"><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="quantity" label="数量" min-width="100" align="right" /></el-table>
</template></el-drawer>
@@ -119,6 +189,6 @@ onMounted(load)
</template>
<style scoped>
.return-inventory-page { padding: 20px; } .page-heading { margin-bottom: 14px; } h2 { margin: 0; font-size: 22px; } p { margin: 6px 0 0; color: var(--el-text-color-secondary); } .filter-bar { display: grid; grid-template-columns: repeat(3, minmax(150px, 1fr)) auto; gap: 10px; margin-bottom: 14px; } .el-pagination { margin-top: 16px; justify-content: flex-end; } .drawer-heading { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 12px; } .drawer-heading h3 { margin: 0; font-size: 16px; }
@media (max-width: 760px) { .return-inventory-page { padding: 12px; } .filter-bar { grid-template-columns: 1fr 1fr; } }
.return-inventory-page { padding: 20px; } .page-heading { margin-bottom: 14px; } h2 { margin: 0; font-size: 22px; } p { margin: 6px 0 0; color: var(--el-text-color-secondary); } .filter-bar { display: grid; grid-template-columns: repeat(2, minmax(180px, 320px)) auto; justify-content: start; gap: 10px; margin-bottom: 14px; } .filter-bar.carton-filters { grid-template-columns: repeat(4, minmax(150px, 1fr)) 130px auto; } .filter-bar > * { min-width: 0; } .inventory-time { white-space: nowrap; } .el-pagination { margin-top: 16px; justify-content: flex-end; } .drawer-heading { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 12px; } .drawer-heading h3 { margin: 0; font-size: 16px; }
@media (max-width: 760px) { .return-inventory-page { padding: 12px; } .filter-bar, .filter-bar.carton-filters { grid-template-columns: 1fr 1fr; } }
</style>
@@ -19,5 +19,34 @@ test('新建退货预报选择海外仓后带出联系人和电话', () => {
test('新建退货预报使用加宽的双列表单弹窗', () => {
assert.match(view, /title="新建退货预报" width="1120px"/)
assert.match(view, /<el-form label-width="190px" class="return-create-form">/)
assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/)
})
test('退货列表和详情使用客户可读仓库、状态与时间', () => {
assert.match(view, /function operationWarehouseLabel\(warehouseId: string\)/)
assert.match(view, /return warehouse \? warehouseLabel\(warehouse\) : '未关联'/)
assert.match(view, /PENDING_RECEIPT: '待签收'/)
assert.match(view, /PARTIALLY_RECEIVED: '部分签收'/)
assert.match(view, /COMPLETED: '已完成'/)
assert.match(view, /function formatDateTime\(value\?: string \| null\)/)
assert.match(view, /\{\{ operationWarehouseLabel\(row\.overseasWarehouseId\) \}\}/)
assert.match(view, /\{\{ statusLabel\(row\.status\) \}\}/)
assert.match(view, /\{\{ formatDateTime\(row\.createdAt\) \}\}/)
assert.doesNotMatch(view, /<el-table-column prop="overseasWarehouseId"/)
assert.doesNotMatch(view, /\{\{ selected\.order\.overseasWarehouseId \}\}/)
})
test('可操作退货表格把操作列固定在最左侧', () => {
const mainTable = view.slice(view.indexOf('<el-table v-loading="loading"'), view.indexOf('<el-pagination'))
const createTable = view.slice(view.indexOf('<el-table :data="form.items"'), view.indexOf('</el-table>', view.indexOf('<el-table :data="form.items"')))
assert.match(mainTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="90" fixed="left">/)
assert.match(createTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="80" fixed="left">/)
})
test('分页初始化不会重复请求退货列表', () => {
assert.match(view, /function changePage\(page: number\) \{\s*if \(page === query\.page\) return/)
assert.match(view, /:current-page="query\.page" :page-size="query\.size"/)
assert.match(view, /@current-change="changePage"/)
assert.doesNotMatch(view, /@current-change="load"/)
})
@@ -2,8 +2,8 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@lucide/vue'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/api/firstLeg'
import { createReturnOrder, getReturnOrder, listReturnOrders, type ReturnOrderDetail, type ReturnOrderSummary } from '../api/returnOrders'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import { createReturnOrder, getReturnOrder, listReturnOrders, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
const loading = ref(false)
const creating = ref(false)
@@ -32,6 +32,10 @@ const duplicateSkuIds = computed(() => {
})
function resetForm() { Object.assign(form, { overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) }
function warehouseLabel(warehouse: MerchantWarehouse) { return `${warehouse.warehouseName} · ${warehouse.warehouseCode}` }
function operationWarehouseLabel(warehouseId: string) {
const warehouse = operationWarehouses.value.find(item => String(item.id) === String(warehouseId))
return warehouse ? warehouseLabel(warehouse) : '未关联'
}
function selectOperationWarehouse() {
const warehouse = operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId)
form.warehouseContactName = warehouse?.contactName || ''
@@ -57,15 +61,20 @@ async function loadOperationWarehouses() {
function openCreate() {
resetForm()
showCreate.value = true
void loadOperationWarehouses()
if (!operationWarehouses.value.length && !operationWarehousesLoading.value) void loadOperationWarehouses()
}
function addItem() { form.items.push(emptySku()) }
function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) }
async function load() {
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 = response.total }
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) }
finally { loading.value = false }
}
function changePage(page: number) {
if (page === query.page) return
query.page = page
void load()
}
async function openDetail(returnNo: string) { selected.value = await getReturnOrder(returnNo); showDetail.value = true }
async function submit() {
const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity }))
@@ -79,7 +88,17 @@ async function submit() {
} finally { creating.value = false }
}
function statusType(status: string) { return status === 'COMPLETED' ? 'success' : status === 'PARTIALLY_RECEIVED' ? 'warning' : 'info' }
onMounted(load)
function statusLabel(status: string) {
return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '部分签收', COMPLETED: '已完成' } as Record<string, string>)[status] ?? '未知状态'
}
function startedByLabel(startedBy: ReturnReceipt['startedBy']) { return startedBy === 'SKU' ? 'SKU 查询' : '退货单号' }
function formatDateTime(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
}
onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
</script>
<template>
@@ -93,22 +112,22 @@ onMounted(load)
<el-button @click="query.page=1; load()"><Search :size="16" />查询</el-button>
</div>
<el-table v-loading="loading" :data="rows" @row-click="row => openDetail(row.returnNo)">
<el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row.returnNo)">详情</el-button></template></el-table-column>
<el-table-column prop="returnNo" label="退货单号" min-width="190" />
<el-table-column prop="customerReferenceNo" label="客户参考号" min-width="150" />
<el-table-column prop="overseasWarehouseId" label="操作仓" min-width="120" />
<el-table-column label="操作仓" min-width="190"><template #default="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
<el-table-column label="换标" width="100"><template #default="{ row }"><el-tag v-if="row.needRelabel" type="warning">需要换标</el-tag><span v-else>否</span></template></el-table-column>
<el-table-column prop="skuTypes" label="SKU种类" width="100" />
<el-table-column prop="expectedQuantity" label="预报数量" width="110" />
<el-table-column prop="receivedQuantity" label="实收数量" width="110" />
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170" />
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row.returnNo)">详情</el-button></template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
<el-pagination :current-page="query.page" :page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="changePage" />
<el-dialog v-model="showCreate" title="新建退货预报" width="1120px" destroy-on-close>
<el-form label-width="160px" class="return-create-form">
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
<el-form label-width="190px" class="return-create-form">
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
@@ -118,14 +137,14 @@ onMounted(load)
<div class="return-relabel-option"><span>换标</span><el-checkbox v-model="form.needRelabel">需要换标</el-checkbox></div>
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
<el-table :data="form.items" row-key="id" size="small">
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
<el-table-column label="SKU" min-width="200"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
</el-table>
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
</el-dialog>
<el-drawer v-model="showDetail" title="退货签收详情" size="760px"><template v-if="selected"><el-descriptions :column="2" border><el-descriptions-item label="退货单号">{{ selected.order.returnNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(selected.order.status)">{{ selected.order.status }}</el-tag></el-descriptions-item><el-descriptions-item label="海外仓">{{ selected.order.overseasWarehouseId }}</el-descriptions-item><el-descriptions-item label="实收 / 预报">{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</el-descriptions-item><el-descriptions-item label="海外仓联系人">{{ selected.order.warehouseContactName || '—' }}</el-descriptions-item><el-descriptions-item label="海外仓联系电话">{{ selected.order.warehouseContactPhone || '—' }}</el-descriptions-item></el-descriptions><p v-if="selected.remark" class="remark">备注:{{ selected.remark }}</p><h3>预报与实收</h3><el-table :data="selected.items" size="small"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="expectedQuantity" label="预报" /><el-table-column prop="receivedQuantity" label="实收" /></el-table><h3>签收批次</h3><el-table :data="selected.receipts" size="small"><el-table-column prop="receiptBatchNo" label="批次号" min-width="170" /><el-table-column prop="receiptNo" label="签收单号" min-width="170" /><el-table-column prop="cartonMark" label="箱号" min-width="190" /><el-table-column prop="trackingNo" label="快递单号"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column prop="startedBy" label="开始方式" /><el-table-column prop="receivedQuantity" label="实收" /><el-table-column prop="signedAt" label="签收时间" min-width="170" /></el-table></template></el-drawer>
<el-drawer v-model="showDetail" title="退货签收详情" size="760px"><template v-if="selected"><el-descriptions :column="2" border><el-descriptions-item label="退货单号">{{ selected.order.returnNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(selected.order.status)">{{ statusLabel(selected.order.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="海外仓">{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="实收 / 预报">{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</el-descriptions-item><el-descriptions-item label="海外仓联系人">{{ selected.order.warehouseContactName || '—' }}</el-descriptions-item><el-descriptions-item label="海外仓联系电话">{{ selected.order.warehouseContactPhone || '—' }}</el-descriptions-item></el-descriptions><p v-if="selected.remark" class="remark">备注:{{ selected.remark }}</p><h3>预报与实收</h3><el-table :data="selected.items" size="small"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="expectedQuantity" label="预报" /><el-table-column prop="receivedQuantity" label="实收" /></el-table><h3>签收批次</h3><el-table :data="selected.receipts" size="small"><el-table-column prop="receiptBatchNo" label="批次号" min-width="170" /><el-table-column prop="receiptNo" label="签收单号" min-width="170" /><el-table-column prop="cartonMark" label="箱号" min-width="190" /><el-table-column prop="trackingNo" label="快递单号"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column label="开始方式"><template #default="{ row }">{{ startedByLabel(row.startedBy) }}</template></el-table-column><el-table-column prop="receivedQuantity" label="实收" /><el-table-column label="签收时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.signedAt) }}</template></el-table-column></el-table></template></el-drawer>
</section>
</template>
@@ -68,7 +68,8 @@ const labelingModeHint = computed(() => labelingMode.value === 'carton'
? '只替换箱内商品 SKU 标签,保留原箱唛;同一箱唛可以添加多条 SKU 明细。'
: '同一箱同时替换商品 SKU 标签和外箱箱唛;同一箱唛的多条 SKU 使用同一个新 FBA 箱唛和目的仓。')
const submittedRows = ref<Record<string, string>[]>([])
const selectedDetail = ref<Record<string, any> | null>(null)
interface ServiceDetail extends Record<string, unknown> { transferNo?: string; status?: string; estimatedShipAt?: string; actualShipAt?: string; cartons?: TransferCarton[] }
const selectedDetail = ref<ServiceDetail | null>(null)
const selectedRelabelDetail = ref<RelabelDetail | null>(null)
const fbaWarehouses = ref<MerchantWarehouse[]>([])
const operationWarehouses = ref<MerchantWarehouse[]>([])
@@ -569,7 +570,7 @@ async function submit() {
async function openDetail(value: Record<string, string>) {
try {
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
selectedDetail.value = { ...value, ...(detail as Record<string, unknown>) as Record<string, string> }
selectedDetail.value = { ...value, ...(detail as ServiceDetail) }
selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null
mode.value = 'detail'
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') }
@@ -578,7 +579,7 @@ async function confirmTransferMeasurements() {
const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '')
if (!transferNo) return
try { await ElMessageBox.confirm('确认 PDA 回传的箱数、尺寸、重量和打托数据吗?确认后将按实测数据入库。', '确认实测数据', { confirmButtonText: '确认入库', cancelButtonText: '取消' }) } catch { return }
try { selectedDetail.value = await confirmTransferMeasurement(transferNo) as Record<string, string>; ElMessage.success('实测数据已确认并入库') }
try { selectedDetail.value = await confirmTransferMeasurement(transferNo) as ServiceDetail; ElMessage.success('实测数据已确认并入库') }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '确认失败') }
}
async function printTransferCartons() {